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

parent a831d8f6
# ─────────────────────────────────────────────────────────────────
# SSO VietProDev — Patroni Docker Image
# Based on postgres:17-alpine, includes Patroni + etcd-client + HA utilities
#
# Build:
# docker build -t sso-vietprodev-postgres:latest -f Dockerfile.patroni .
#
# Usage: This image is used by docker-compose.ha.yml for the Patroni
# cluster nodes (postgres1, postgres2, postgres3).
# Each container runs Patroni as PID 1 which manages Postgres.
# ─────────────────────────────────────────────────────────────────
FROM postgres:17-alpine
# Metadata
LABEL maintainer="SSO VietProDev"
LABEL description="PostgreSQL 17 with Patroni for HA SSO cluster"
# ── Install Patroni & dependencies ──────────────────────────────────
# Patroni requires Python 3, etcd client, and several Python packages.
# We pin versions to avoid breakage from upstream changes.
RUN apk add --no-cache \
python3 \
py3-pip \
py3-wheel \
curl \
jq \
nano \
postgresql-contrib \
&& pip3 install --no-cache-dir --break-system-packages \
patroni[etcd]==3.3.2 \
psycopg2-binary==2.9.10 \
python-etcd==0.4.5 \
&& rm -rf /var/cache/apk/* /tmp/pip-* /root/.cache/pip
# ── Patroni config directory ────────────────────────────────────────
# Patroni reads /run/patroni.yml on startup. The docker-compose.ha.yml
# passes config via environment variables (PATRONI_* vars) which Patroni
# auto-converts into a config dict. No file mounting needed.
# For custom overrides, mount a volume at /etc/patroni/override.yml.
RUN mkdir -p /etc/patroni /run /var/lib/postgresql/wal_archive && \
chown postgres:postgres /run /var/lib/postgresql/wal_archive
# ── Health check script ──────────────────────────────────────────────
# Patroni exposes the REST API on port 8008. The docker-compose.ha.yml
# HAProxy uses this for backend health checks.
COPY <<EOF /usr/local/bin/patroni-healthcheck.sh
#!/bin/sh
# Patroni health check: GET /patroni returns JSON with role.
# 200 + role=master/replica → healthy. Anything else → unhealthy.
response=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8008/patroni 2>/dev/null)
if [ "$response" = "200" ]; then
role=$(curl -s http://localhost:8008/patroni 2>/dev/null | jq -r '.role // "unknown"')
echo "Patroni OK — role: $role"
exit 0
else
echo "Patroni unhealthy (HTTP $response)"
exit 1
fi
EOF
RUN chmod +x /usr/local/bin/patroni-healthcheck.sh
# ── Entry point ─────────────────────────────────────────────────────
# Patroni must run as PID 1. We use its built-in docker/patroni entry point.
# The image default CMD is already: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"]
# We need to override to run Patroni instead.
COPY <<EOF /entrypoint.sh
#!/bin/bash
set -e
# Patroni expects the PostgreSQL data directory owned by postgres.
# The base postgres image handles this in its entrypoint, but since
# we're overriding CMD we replicate the essential parts.
chown postgres:postgres /var/lib/postgresql/data 2>/dev/null || true
chown postgres:postgres /run 2>/dev/null || true
chown postgres:postgres /var/lib/postgresql/wal_archive 2>/dev/null || true
# Ensure patronictl can connect. Patroni sets up a postgres user with
# replication privileges via its DCS (etcd). We just need postgres user to exist.
# The base postgres image already creates the postgres user.
# Run Patroni as PID 1. It manages Postgres lifecycle.
exec patroni /etc/patroni/patroni.yml
EOF
RUN chmod +x /entrypoint.sh
EXPOSE 8008 # Patroni REST API (used by HAProxy for health checks)
# Postgres port 5432 is managed by Patroni automatically
ENTRYPOINT ["/entrypoint.sh"]
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -992,3 +992,105 @@ Other `console.*` calls in `src/utils/logger.ts`, `src/config/index.ts`, schedul ...@@ -992,3 +992,105 @@ Other `console.*` calls in `src/utils/logger.ts`, `src/config/index.ts`, schedul
- `npx tsc --noEmit` → exit 0 - `npx tsc --noEmit` → exit 0
- `pnpm swagger:generate` → 50 paths registered, OpenAPI spec written - `pnpm swagger:generate` → 50 paths registered, OpenAPI spec written
- Dev server running on port 3001; `/health`, `/.well-known/openid-configuration`, `/oauth/jwks`, `/swagger/index`, `/swagger/openapi.json` all return 200; protected endpoints return 401 unauthenticated as expected. - Dev server running on port 3001; `/health`, `/.well-known/openid-configuration`, `/oauth/jwks`, `/swagger/index`, `/swagger/openapi.json` all return 200; protected endpoints return 401 unauthenticated as expected.
---
## §19 — Session 6: Bug Fixes + Phase 4 Production Readiness (2026-06-22)
### Bug: `column "backchannel_logout_uri" does not exist` → OIDC provider failed
**Root cause:** Migration `043-add-backchannel-logout-clients.sql` đã chạy trên database Docker `sso` thay vì local Windows `vietprodev_sso`. Khi `ClientProvider.listActive()` chạy query, gặp column missing → `OidcService.initialize()` reject → `instance` không được set → cascade `getCallbackMiddleware()` throw.
**Fix:** Chạy migration trên đúng DB:
```sql
-- Local Windows PostgreSQL, database: vietprodev_sso
ALTER TABLE clients ADD COLUMN IF NOT EXISTS backchannel_logout_uri TEXT;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS backchannel_logout_session_required BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE clients SET backchannel_logout_uri = 'http://localhost:4001/auth/backchannel-logout',
backchannel_logout_session_required = true WHERE client_id = 'project-a-demo';
UPDATE clients SET backchannel_logout_uri = 'http://localhost:4002/auth/backchannel-logout',
backchannel_logout_session_required = true WHERE client_id = 'project-b-demo';
```
### Sync: project-b-demo/server.js
`project-b-demo/server.js` thiếu silent SSO flow so với `project-a-demo/server.js`. Đồng bộ hóa:
- Home route `/` → redirect to `/auth/silent-login` (không còn hiển thị nút)
- `/auth/silent-login` endpoint với `prompt=none`
- `/auth/callback` handle `login_required` + `consent_required` → fallback interactive login
- `/auth/backchannel-logout` POST endpoint
- Session config: `saveUninitialized: false`, `name: project-b.sid`
### Phase 4.4: HTTPS Redirect Middleware
**File:** `src/middlewares/httpsRedirect.ts`
- Redirects HTTP → HTTPS (308 Permanent Redirect) khi `NODE_ENV != development`
- Support `X-Forwarded-Proto` từ trusted proxies (loopback)
- Skip health check paths: `/health`, `/healthz`, `/ready`, `/favicon.ico`
- Registered FIRST in `server.ts` `initServer()` — trước cả CORS
### Phase 4.5: OIDC Signing Key Rotation
**Files:**
- `src/jobs/secretRotation.ts` — RSA key generation, rotation logic, retirement cleanup, keystore persistence
- `src/config/oidcConfigService.ts` — thêm `jwkFile` config
- `src/types/oidc-provider.d.ts` — thêm declaration cho `ExternalSigningKey`
**Architecture:**
oidc-provider v9.8.4 `initialize_keystore.js` dùng `structuredClone()` để deep-clone input JWK. Nếu có `d` field → validate toàn bộ RSA private components (`d, p, q, dp, dq, qi`). Nếu `d = ""` → crash.
Giải pháp: dùng `ExternalSigningKey` class từ `oidc-provider/lib/helpers/keystore.js`. Class này bypasses private-field validation (chỉ check `sign()` method tồn tại), cho phép lưu full JWK trên disk mà oidc-provider không báo lỗi.
```typescript
class RsaExternalSigningKey extends ExternalSigningKey {
constructor(private jwk) {
super();
this.kid = jwk.kid;
this.alg = jwk.alg ?? 'RS256';
}
keyObject() {
return crypto.createPrivateKey({ key: this.jwk, format: 'jwk' });
}
async sign(data): Promise<Buffer> {
return crypto.sign(null, Buffer.from(data), this.keyObject());
}
}
```
**Flow:**
1. Server start → load key từ `config/keys/keystore.json`
2. Wrap key trong `RsaExternalSigningKey`
3. oidc-provider dùng cho signing (gọi `.sign()`) và JWKS endpoint (chỉ expose public parts)
4. Rotation: `rotateSigningKey()` → mark old key pending retirement (+24h grace period)
5. `retireOldKeys()` → remove keys đã qua deadline (gọi mỗi server start)
**Key files:**
- `config/keys/keystore.json` — chứa full JWK (public + private), never committed
- `src/jobs/secretRotation.ts` — rotation logic
### Phase 4.6: OpenAPI Final Scan
- `pnpm swagger:generate` → 69 operations, all domains registered
- `pnpm swagger:check` → ✅ All `$ref` resolved, all `operationId` present and unique
- Baseline created: `storage/swagger/openapi.baseline.json`
### Phase 4.7: Documentation
- `RUN.md` — Added §14 Production Deployment (env vars, build, key rotation, health check)
- `PLANS.md` — Updated Phase 4 file list
- `README.md` — Added production features section
- `RELEASE_NOTES.md` — Created v1.0.0 release notes
### Phase 4.8: Smoke Test
```
npx tsc --noEmit → exit 0
pnpm swagger:validate → 69 operations validated
curl http://localhost:3001/health → {"status":"healthy",...}
curl http://localhost:3001/oauth/jwks → public JWK (kid=1dee62d15159ef65, no 'd' field) ✅
```
...@@ -28,13 +28,16 @@ ...@@ -28,13 +28,16 @@
`SSO VietProDev Backend` là một **OIDC Authorization Server** đầy đủ tính năng, cung cấp: `SSO VietProDev Backend` là một **OIDC Authorization Server** đầy đủ tính năng, cung cấp:
- **OIDC/OAuth2** — Authorization Code, Client Credentials, Refresh Token, RP-Initiated Logout - **OIDC/OAuth2** — Authorization Code, Client Credentials, Refresh Token, RP-Initiated Logout, Back-Channel Logout
- **REST API** — Auth, Users, Roles, Permissions, Files, Notifications, Audit - **REST API** — Auth, Users, Roles, Permissions, Files, Notifications, Audit
- **Email Verification** — User mới phải verify email trước khi login - **Email Verification** — User mới phải verify email trước khi login
- **Custom UI** — Login, Register, Consent, Logout pages (Handlebars) - **Custom UI** — Login, Register, Consent, Logout pages (Handlebars)
- **Audit Logging** — MongoDB-backed outbox pattern - **Audit Logging** — MongoDB-backed outbox pattern
- **Rate Limiting** — Redis-backed - **Rate Limiting** — Redis-backed (5 attempts / 15 min per IP for auth endpoints)
- **OpenAPI 3.0** — Auto-generated từ Zod schemas - **CSRF Protection** — Double-submit cookie + origin validation
- **HTTPS Redirect** — Automatic HTTP → HTTPS in production
- **OIDC Key Rotation** — Zero-downtime signing key rotation với grace period
- **OpenAPI 3.0** — Auto-generated từ Zod schemas, 69 operations
| Service | URL | | Service | URL |
|---------|-----| |---------|-----|
......
# Release Notes — v1.0.0
**Published:** 2026-06-22
**Status:** Production Ready
---
## What's New
### OIDC Authorization Server
- Full OIDC/OAuth2 implementation via `oidc-provider` v9
- Authorization Code + PKCE flow
- ID tokens, access tokens (JWT), refresh tokens (rotating)
- RP-initiated logout + Back-channel logout
- Silent SSO (`prompt=none`) for cross-project authentication
- Custom login/register/consent/logout pages via Handlebars
- Session persistence via PostgreSQL adapter
### Silent SSO Across Demo Apps
- `project-a-demo` (port 4001) and `project-b-demo` (port 4002)
- Auto-redirect to SSO when no local session
- Share SSO session — login once, access both apps
- Back-channel logout propagates to all connected apps
### Security
- CSRF protection (double-submit cookie + origin validation)
- Redis-backed rate limiting (5 attempts / 15 min per IP on auth endpoints)
- HTTPS redirect middleware (auto in production)
- HSTS headers via Helmet
- Content Security Policy (strict in production)
- OIDC signing key rotation (zero-downtime, grace period)
### Audit & Reliability
- MongoDB-backed audit logging with outbox pattern
- Partitioned audit log tables (monthly)
- Dead letter queue for failed audit events
- Health monitor with circuit breaker for DB failover events
- Database backup job (pg_basebackup every 10 min)
### API
- 69 REST endpoints across 6 domains: Auth, User, Role, Permission, File, Notification, Audit
- OpenAPI 3.0 spec auto-generated from Zod schemas
- JWT Bearer token authentication
- Role-based access control (RBAC)
---
## Upgrade Notes
### From pre-v1.0.0
**Database migration required:**
```sql
-- Run on the app database (vietprodev_sso)
ALTER TABLE clients ADD COLUMN IF NOT EXISTS backchannel_logout_uri TEXT;
ALTER TABLE clients ADD COLUMN IF NOT EXISTS backchannel_logout_session_required BOOLEAN NOT NULL DEFAULT FALSE;
```
**Environment variables (new):**
| Variable | Default | Description |
|----------|---------|-------------|
| `NODE_ENV` | `development` | Set `production` for HTTPS redirect + strict CSP |
| `OIDC_JWK_FILE` | `config/keys/keystore.json` | Path to signing key store |
| `ENABLE_RATE_LIMIT` | `true` | Toggle rate limiting |
| `CSRF_HTTPONLY` | `true` | CSRF cookie HttpOnly setting |
| `CSRF_ORIGIN_VALIDATION` | `true` | Enable origin header validation |
| `CSRF_DOUBLE_SUBMIT` | `false` | Enable double-submit pattern |
**Breaking changes:**
- `DB_NAME` default changed from `"sso"` to `"vietprodev_sso"`
- CSRF middleware now active by default in non-dev environments
---
## Migration Path
See [RUN.md](./RUN.md) §14 Production Deployment for full deployment guide including environment variables, build steps, and key rotation.
This diff is collapsed.
{
"keys": [
{
"kid": "1dee62d15159ef65",
"alg": "RS256",
"use": "sig",
"kty": "RSA",
"n": "nQhUHI915oBW5be6rJ4Zsr3k7zhrfaGp_DdO5zBSf0_MLKInTCvFxIBfcK-t2pyep45hl59NI-Bbfm1K_eZLPDFQtEJVG58wJY8PkgLFzb04-xS3HKLGMb30oTM7W4Yq2gaN1P1mSImXrrbYaz2RrbOCXzVkJnZlVYNa70luul6GIION09K7QzjFIZyA_pjCw3121SxeXF9VN3nE49liP99IOCYfbgck1-tF1uc_upyawSPcmvABDg5Uc4CwphTBJkHZkf20K2z6Cet5ID8S62EH00XpnOFMEFsS0QK4CYKWVO2_81PaiSFm-Z9Sduwsjd5pXRrD_j7Dxuu37cu7EQ",
"e": "AQAB",
"d": "cn2s1b-P9ovoz3plk48YRxN5fYqYbicW1XOxBsTBYV4mcjEuKOf0b8PCnvMRKF-Is7tCH4osdAitEw1PrLUVPydPI1Fs1ZBhSjREGSQF-m5vKdRKRyNmql2ucaZb-lIxn0TbPa9QI9_HXifYVfG0Uf3PabTdXPp1mr0ET_KT1ZmkAcjHgcKrqtDRmBquR8o4faUGEDOD88VgVsYi23Y-SXO1YOLpq9mkWiyabLLZfFrQoRTP02LaCDotNzj7WZXUlqbD8w9Ry9PAA8T6UNx1DZmFxvRlMPd1PmiTDuBAaXqO-5YmhUyNJujw8GsfDaasIA-0eIE5ToLJFwtEyN1R",
"p": "zjR2zdyGByLea0183eTlLNdpecORop9hTYJtToweomf7f-rogby9ZRl0oiFyhfOtIp8QKwfkZsg4O_Aja0w9u2YnlvJHiZtk5Qx6vLzFQnU2LzrkgWk3Bi1ElnutxHW6wUTA0ofMPlPklKbg6j-RBDWZkI4QoY8ovCNOMvMv1vk",
"q": "wvQKBotfYzxIZx5_rhgG-XRjp-J1HQuIPkgCjXqjXRfhC6v0C1Znsvw6w4fH4It5COwcjWylCqV2WXc6KZ1dh6waUs6Vf1Dx3jUqzAtea5B927aEa1yofbHlJ0Y-g2adbFRZLtVG6Umof2oMBccQX3HwdIEfQ4qJuxr35IlpEtk",
"dp": "NOp9umJm-pnWHg8qhf7hWNqGtLqdOvBPlgZsaoXGrsKkZbUwqWp35-PabGjM7NcVjRbonUJPOJkU5TzAuh48kUSr0C6ocQWxpJ3JOXnv3RgbKY-haBxKBInFWiCu_QWBQWuVV_GFVlFNDWCtwy9A1aWznMb4OZ11RKKCxMxncHE",
"dq": "fXzqiyXhY5YlIMNf-mca7i1-DE3XhreqnjRp-DmtuDmrsYCg2T9oBq0XAk9_WElOwnCBPINnhP_Wu2XkeW8DCla8pvq0_jvTRG5Cuw7CW0ipyuikOhttlWMSFwK_MIO350gwE9ZTC81O-Z0AWiO1pUpJNiT7Q9WELH0Cfgf5K5k",
"qi": "iKDeZx0yN4qjMhCvPoQxUG7DHgp9YCW6jVUtT2yJeYAe4vK0ZNAFvOQm-i4rGcXdGLjO4_TIeV4UQr5EuD8eGUzYa5_lU2U_UqtYw3ZWSsS9xMwzIT8pVbe1eph9ruWqI8kTiaXEVNiI68COCScPDyX5IccKUwqi3v_eDyVnhNc",
"createdAt": "2026-06-22T12:35:34.965Z"
}
],
"activeKid": "1dee62d15159ef65"
}
\ No newline at end of file
...@@ -101,18 +101,40 @@ services: ...@@ -101,18 +101,40 @@ services:
retries: 5 retries: 5
# ── PostgreSQL Patroni cluster ───────────────────────────────── # ── PostgreSQL Patroni cluster ─────────────────────────────────
# NOTE: Requires custom Docker image with Patroni installed. # Each node runs Patroni as PID 1. Patroni reads ETCD_HOST from env,
# Build with: docker build -t sso-vietprodev-postgres -f Dockerfile.patroni . # auto-generates /etc/patroni/patroni.yml from PATRONI_* env vars,
# then starts Postgres. Requires custom image sso-vietprodev-postgres:latest.
#
# Ports:
# 5432 — Postgres (managed by Patroni)
# 8008 — Patroni REST API (HAProxy health check target)
# ─────────────────────────────────────────────────────────────────
postgres1: postgres1:
image: sso-vietprodev-postgres:latest image: sso-vietprodev-postgres:latest
container_name: sso-postgres1 container_name: sso-postgres1
hostname: postgres1 hostname: postgres1
user: postgres
environment: environment:
PATRONI_SCOPE: sso-postgres PATRONI_SCOPE: sso-postgres
PATRONI_NAME: postgres1 PATRONI_NAME: postgres1
PATRONI_RESTAPI__LISTEN: 0.0.0.0:8008
PATRONI_POSTGRESQL__LISTEN: 0.0.0.0:5432
PATRONI_POSTGRESQL__DATA_DIR: /var/lib/postgresql/data
PATRONI_POSTGRESQL__PARAMETERS__WAL_LEVEL: replica
PATRONI_POSTGRESQL__PARAMETERS__MAX_WAL_SENDERS: 10
PATRONI_POSTGRESQL__PARAMETERS__MAX_REPLICATION_SLOTS: 10
PATRONI_POSTGRESQL__PARAMETERS__HOT_STANDBY: 'on'
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__USERNAME: replicator
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__PASSWORD: repl-password-replace-in-prod
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__USERNAME: postgres
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__PASSWORD: '@dmin123'
PATRONI_ETCD_HOST: etcd1:2379
ETCDCTL_API: '3'
ports:
- '5433:5432' # Dev convenience: map external 5433 → internal 5432
- '8009:8008' # Dev convenience: map external 8009 → internal 8008 (Patroni API)
volumes: volumes:
- postgres1-data:/var/lib/postgresql/data - postgres1-data:/var/lib/postgresql/data
- postgres1-wal:/var/lib/postgresql/wal_archive
networks: networks:
- sso-network - sso-network
restart: unless-stopped restart: unless-stopped
...@@ -121,12 +143,28 @@ services: ...@@ -121,12 +143,28 @@ services:
image: sso-vietprodev-postgres:latest image: sso-vietprodev-postgres:latest
container_name: sso-postgres2 container_name: sso-postgres2
hostname: postgres2 hostname: postgres2
user: postgres
environment: environment:
PATRONI_SCOPE: sso-postgres PATRONI_SCOPE: sso-postgres
PATRONI_NAME: postgres2 PATRONI_NAME: postgres2
PATRONI_RESTAPI__LISTEN: 0.0.0.0:8008
PATRONI_POSTGRESQL__LISTEN: 0.0.0.0:5432
PATRONI_POSTGRESQL__DATA_DIR: /var/lib/postgresql/data
PATRONI_POSTGRESQL__PARAMETERS__WAL_LEVEL: replica
PATRONI_POSTGRESQL__PARAMETERS__MAX_WAL_SENDERS: 10
PATRONI_POSTGRESQL__PARAMETERS__MAX_REPLICATION_SLOTS: 10
PATRONI_POSTGRESQL__PARAMETERS__HOT_STANDBY: 'on'
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__USERNAME: replicator
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__PASSWORD: repl-password-replace-in-prod
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__USERNAME: postgres
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__PASSWORD: '@dmin123'
PATRONI_ETCD_HOST: etcd2:2379
ETCDCTL_API: '3'
ports:
- '5434:5432'
- '8010:8008'
volumes: volumes:
- postgres2-data:/var/lib/postgresql/data - postgres2-data:/var/lib/postgresql/data
- postgres2-wal:/var/lib/postgresql/wal_archive
networks: networks:
- sso-network - sso-network
restart: unless-stopped restart: unless-stopped
...@@ -135,26 +173,43 @@ services: ...@@ -135,26 +173,43 @@ services:
image: sso-vietprodev-postgres:latest image: sso-vietprodev-postgres:latest
container_name: sso-postgres3 container_name: sso-postgres3
hostname: postgres3 hostname: postgres3
user: postgres
environment: environment:
PATRONI_SCOPE: sso-postgres PATRONI_SCOPE: sso-postgres
PATRONI_NAME: postgres3 PATRONI_NAME: postgres3
PATRONI_RESTAPI__LISTEN: 0.0.0.0:8008
PATRONI_POSTGRESQL__LISTEN: 0.0.0.0:5432
PATRONI_POSTGRESQL__DATA_DIR: /var/lib/postgresql/data
PATRONI_POSTGRESQL__PARAMETERS__WAL_LEVEL: replica
PATRONI_POSTGRESQL__PARAMETERS__MAX_WAL_SENDERS: 10
PATRONI_POSTGRESQL__PARAMETERS__MAX_REPLICATION_SLOTS: 10
PATRONI_POSTGRESQL__PARAMETERS__HOT_STANDBY: 'on'
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__USERNAME: replicator
PATRONI_POSTGRESQL__AUTHENTICATION__REPLICATION__PASSWORD: repl-password-replace-in-prod
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__USERNAME: postgres
PATRONI_POSTGRESQL__AUTHENTICATION__POSTGRES__PASSWORD: '@dmin123'
PATRONI_ETCD_HOST: etcd3:2379
ETCDCTL_API: '3'
ports:
- '5435:5432'
- '8011:8008'
volumes: volumes:
- postgres3-data:/var/lib/postgresql/data - postgres3-data:/var/lib/postgresql/data
- postgres3-wal:/var/lib/postgresql/wal_archive
networks: networks:
- sso-network - sso-network
restart: unless-stopped restart: unless-stopped
# ── HAProxy ────────────────────────────────────────────────────── # ── HAProxy ──────────────────────────────────────────────────────
# Routes: :5432 (write) → primary PG | :5433 (read) → all replicas # Routes:
# :5000 (internal) → primary Postgres | :5001 (internal) → all replicas
# Stats UI: internal 8404 → external 7000
# App connects via PgBouncer (see below).
haproxy: haproxy:
image: haproxy:2.9 image: haproxy:2.9
container_name: sso-haproxy container_name: sso-haproxy
hostname: haproxy hostname: haproxy
ports: ports:
- '5433:5432' # Write endpoint - '7000:8404' # Stats UI: external 7000 → internal 8404
- '5434:5433' # Read endpoint
- '8404:8404' # Stats UI
volumes: volumes:
- ./infrastructure/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro - ./infrastructure/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
depends_on: depends_on:
...@@ -174,20 +229,22 @@ services: ...@@ -174,20 +229,22 @@ services:
retries: 3 retries: 3
# ── PgBouncer ─────────────────────────────────────────────────── # ── PgBouncer ───────────────────────────────────────────────────
# Connection pooling in transaction mode in front of HAProxy # Connection pooling in transaction mode. App connects to PgBouncer :6432 (external).
# PgBouncer routes writes → HAProxy :5000 (master), reads → HAProxy :5001 (replicas).
pgbouncer: pgbouncer:
image: edoburu/pgbouncer:latest image: edoburu/pgbouncer:latest
container_name: sso-pgbouncer container_name: sso-pgbouncer
hostname: pgbouncer hostname: pgbouncer
environment: environment:
DATABASE_URL: postgres://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@haproxy:5432/${DB_NAME:-sso} DATABASE_URL: postgres://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@haproxy:5000/${DB_NAME:-sso}
POOL_MODE: transaction POOL_MODE: transaction
MAX_CLIENT_CONN: 500 MAX_CLIENT_CONN: 500
DEFAULT_POOL_SIZE: 25 DEFAULT_POOL_SIZE: 25
ports: ports:
- '6432:5432' - '6432:5432' # External: app connects here
volumes: volumes:
- ./infrastructure/pgbouncer/userlist.txt:/etc/pgbouncer/userlist.txt:ro - ./infrastructure/pgbouncer/userlist.txt:/etc/pgbouncer/userlist.txt:ro
- ./infrastructure/pgbouncer/pgbouncer.ini:/etc/pgbouncer/pgbouncer.ini:ro
depends_on: depends_on:
haproxy: haproxy:
condition: service_started condition: service_started
...@@ -202,3 +259,6 @@ volumes: ...@@ -202,3 +259,6 @@ volumes:
postgres1-data: postgres1-data:
postgres2-data: postgres2-data:
postgres3-data: postgres3-data:
postgres1-wal:
postgres2-wal:
postgres3-wal:
# REST API Auth vs OIDC Flow — Comparison Guide
> So sánh hai auth mechanism: REST API (local JWT) và OIDC (SSO-based).
> Cả hai đều được implement trong project-a-demo và project-b-demo.
---
## Quick Comparison
| Aspect | REST API Auth | OIDC SSO Auth |
|---|---|---|
| Identity Provider | Local app | SSO Server |
| Login UI | App's own form | SSO's login page |
| Token Issuer | Local app | SSO Server |
| Session | Local JWT (HttpOnly cookie) | SSO tokens + local JWT |
| Logout | Local only | Local + SSO (RP-initiated) |
| SSO features (MFA, etc.) | Not available | Available at SSO |
| Implementation | Simpler | More complex |
| User migration | Local | SSO-linked (auto-provisioning) |
---
## When to Use REST API Auth
- **Internal apps**: Apps that don't need cross-app SSO
- **Legacy apps**: Migrating existing apps that already have user databases
- **Simple use cases**: When you only need basic email/password auth
- **Testing/Development**: Quick prototyping without SSO dependency
- **Hybrid apps**: Apps that support both local auth AND SSO
### Pros
- No external dependency on SSO server
- Simpler debugging — all tokens are local
- Faster login (no redirect)
- Works offline
### Cons
- No SSO features (MFA, SSO policies)
- Separate user accounts per app
- User must create separate accounts for each app
---
## When to Use OIDC SSO Auth
- **Multi-app ecosystem**: Apps that need shared identity across apps
- **Enterprise apps**: Need centralized SSO/MFA policies
- **External apps**: Apps built by third parties that need SSO
- **Zero-trust architecture**: Centralized identity management
### Pros
- Single login across all apps
- Centralized user management
- SSO policies (MFA, password policy, session limits)
- Back-channel logout — force logout across all apps
- Standards-compliant (OAuth 2.0, OIDC)
### Cons
- More complex implementation
- SSO server is a critical dependency
- Slower login (redirect, SSO page)
- Requires network access to SSO
---
## Security Comparison
| Security Feature | REST API | OIDC SSO |
|---|---|---|
| Password policy | App-controlled | SSO-controlled |
| MFA/2FA | App must implement | Built-in at SSO |
| Session management | Local | SSO + local |
| Token replay detection | Via session DB | Via session DB |
| Brute force protection | Rate limiting | SSO rate limiting |
| Logout propagation | App only | SSO logs out all apps |
| Token storage | HttpOnly cookie | HttpOnly cookie |
---
## Dual Auth (Both Flows Supported)
project-a-demo và project-b-demo hỗ trợ **cả hai flow** cùng lúc:
```
# REST API — local credentials
POST /api/v1/auth/login
Body: { email, password }
→ HttpOnly cookies (local JWT)
# OIDC — SSO login
GET /auth/oidc/login
→ Redirect to SSO
→ Callback
→ Local session
```
User tự chọn flow nào:
- **REST**: Gõ email/password trực tiếp trên app
- **OIDC**: Click "Sign in with SSO" → redirect sang SSO
---
## Migration Path: REST → OIDC
```
1. Phase 1: Add REST API auth
(project-a-demo is ready)
2. Phase 2: Add OIDC routes
(Phase B adds /auth/oidc/*)
3. Phase 3: Enable SSO login button
(already implemented)
4. Phase 4: Deprecate REST login
(optional — keep both flows)
```
---
## Token Lifecycle Comparison
### REST API Flow
```
Login
POST /api/v1/auth/login
→ bcrypt verify password
→ Generate JWT access + refresh
→ Set HttpOnly cookies
→ Store token hash in user_sessions
Access (every request)
Cookie: access_token
→ Middleware: verify JWT
→ Attach user to request
Refresh (when access token expires)
POST /api/v1/auth/refresh
→ Verify refresh token
→ Rotate: revoke old, issue new
→ Set new HttpOnly cookies
Logout
POST /api/v1/auth/logout
→ Revoke session in DB
→ Clear cookies
→ Done (local only)
```
### OIDC Flow
```
1. Initiate
GET /auth/oidc/login
→ Generate state + nonce + PKCE code_verifier
→ Store in server-side state store
→ Redirect to SSO /oauth/authorize
2. SSO interaction
User sees SSO login page
User authenticates at SSO
SSO issues authorization code
SSO redirects back to /auth/oidc/callback
3. Token exchange
GET /auth/oidc/callback?code=...
→ Validate state + nonce
→ Exchange code for tokens
POST /oauth/token
→ Receive access_token, id_token, refresh_token from SSO
4. User provisioning
→ Fetch /oauth/userinfo
→ Auto-create or link local user
→ Create local session (local JWT)
→ Set local HttpOnly cookies
5. Access (every request)
Same as REST: local JWT middleware
6. Refresh (when local access token expires)
Same as REST: POST /api/v1/auth/refresh
7. Logout
GET /auth/oidc/logout
→ Revoke local session
→ Redirect to SSO /oauth/logout (RP-initiated)
→ SSO calls back-channel logout to all RPs
→ All apps' sessions revoked
```
This diff is collapsed.
# ───────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
# HAProxy configuration for SSO PostgreSQL HA # HAProxy configuration for SSO HA Cluster
# Routes: 5432 (write) / 5433 (read) # Routes Postgres write/read traffic to the Patroni cluster.
# ───────────────────────────────────────────── #
# Architecture:
# App → PgBouncer :5432 → HAProxy :5000 (write) / :5001 (read)
# HAProxy health checks Patroni REST API (port 8008) on each PG node.
#
# Internal ports (inside docker network sso-network):
# 5000 — pg_write: routes to primary ONLY
# 5001 — pg_read: routes to ALL nodes (load balanced)
# 8404 — Stats Web UI
#
# External ports (docker-compose.ha.yml):
# 7000 → 8404 (stats)
#
# Patroni REST API: GET :8008/patroni returns JSON:
# { "role": "master" | "replica", "state": "running", ... }
# HTTP 200 = node is up.
# ─────────────────────────────────────────────────────────────────
global global
log stdout format raw local0 log stdout format raw local0
maxconn 4096 maxconn 4096
# Unix socket for runtime admin commands (patronictl integration, etc.)
stats socket /var/run/haproxy.sock mode 660 level admin
stats timeout 2m
defaults defaults
log global log global
mode tcp mode tcp
option tcplog
option dontlognull
option redispatch # Reassign session if server goes down
retries 3
timeout connect 5s timeout connect 5s
timeout client 60s timeout client 24h
timeout server 60s timeout server 24h
# ── Write endpoint (primary) ───────────────── # ── Stats Web UI ───────────────────────────────────────────────────
frontend postgres_write listen stats
bind *:5432 mode http
default_backend postgres_primary bind *:8404
stats enable
stats uri /
stats refresh 10s
# Admin access via Unix socket only (not exposed externally)
stats admin if LOCALHOST
backend postgres_primary # ── PG Write Backend (port 5000) ──────────────────────────────────
option httpchk GET /primary # Routes ONLY to the current primary (master) node.
# 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 http-check expect status 200
server pg1 postgres1:5432 check port 8008 inter 5s rise 2 fall 3 default-server inter 5s fall 3 rise 2 on-marked-down shutdown-sessions
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
# ── Read endpoint (replicas) ─────────────── # All 3 nodes participate — HAProxy sends traffic to the first one that
frontend postgres_read # passes the httpchk (the primary). When primary fails:
bind *:5433 # 1. etcd detects lost leader lock (TTL 30s)
default_backend postgres_replica # 2. etcd election → replica promoted to leader
# 3. Patroni REST API on new leader starts returning role=master
# 4. HAProxy httpchk detects role=master on new node
# 5. Next write connection goes to new primary
server postgres1 postgres1:5432 check port 8008 inter 5s fall 3 rise 2
server postgres2 postgres2:5432 check port 8008 inter 5s fall 3 rise 2
server postgres3 postgres3:5432 check port 8008 inter 5s fall 3 rise 2
backend postgres_replica # ── PG Read Backend (port 5001) ──────────────────────────────────
option httpchk GET /replica # Routes to ALL nodes for read scaling. Load balanced with roundrobin.
# 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 http-check expect status 200
server pg1 postgres1:5432 check port 8008 inter 5s rise 2 fall 3 default-server inter 5s fall 3 rise 2 on-marked-down shutdown-sessions
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
# ── Stats UI ───────────────────────────────── # All 3 nodes participate — reads are distributed round-robin.
listen stats # For read scaling, route only to replicas by:
bind *:8404 # option tcp-check
mode http # tcp-check connect port 5432
stats enable # # In production with pg_pool-II or pgcat: use their proxy detection
stats uri /stats server postgres1 postgres1:5432 check port 8008 inter 5s fall 3 rise 2
stats refresh 30s server postgres2 postgres2:5432 check port 8008 inter 5s fall 3 rise 2
server postgres3 postgres3:5432 check port 8008 inter 5s fall 3 rise 2
# ─────────────────────────────────────────────────────────────────
# Patroni configuration template for SSO HA Cluster
#
# Patroni reads this from /etc/patroni/patroni.yml (default).
# In docker-compose.ha.yml, config is passed via PATRONI_* env vars
# which Patroni auto-converts. This file serves as documentation
# and can be used when running outside Docker.
#
# To use: copy to /etc/patroni/patroni.yml on each node,
# then run: patronictl -c /etc/patroni/patroni.yml <command>
#
# Environment variable equivalent (docker-compose):
# PATRONI_<SECTION>__<KEY> = value (double underscore = nested key)
# Example: PATRONI_POSTGRESQL__DATA_DIR = /var/lib/postgresql/data
#
# For each node, PATRONI_NAME must be unique (postgres1, postgres2, postgres3).
# For each node, PATRONI_ETCD_HOST must point to the local etcd (etcd1:2379, etcd2:2379, etcd3:2379).
# ─────────────────────────────────────────────────────────────────
scope: sso-postgres
name: postgres1 # Change to postgres2 / postgres3 on other nodes
# ── REST API ────────────────────────────────────────────────────────
# Used by HAProxy for health checks and leader election.
restapi:
listen: 0.0.0.0:8008
connect_address: postgres1:8008 # Change per node
# ── PostgreSQL ──────────────────────────────────────────────────────
postgresql:
listen: 0.0.0.0:5432
connect_address: postgres1:5432 # Change per node
data_dir: /var/lib/postgresql/data
# Replication configuration
authentication:
replication:
username: replicator
password: "repl-password-replace-in-prod"
postgres:
username: postgres
password: "@dmin123"
# Streaming replication parameters
parameters:
wal_level: replica
max_wal_senders: 10
max_replication_slots: 10
hot_standby: "on"
# Connection limits
max_connections: 100
# WAL settings
wal_keep_size: 128MB
# Performance
shared_buffers: 128MB
effective_cache_size: 512MB
maintenance_work_mem: 64MB
checkpoint_completion_target: 0.9
wal_buffers: 4MB
default_statistics_target: 100
random_page_cost: 1.1
effective_io_concurrency: 200
work_mem: 4MB
min_wal_size: 1GB
max_wal_size: 4GB
# WAL archiving (for backup / PITR)
# Enabled in Phase 3d via archive_mode = on
# archive_command: 'cp %p /var/lib/postgresql/wal_archive/%f'
# ── etcd (Distributed Consensus Store) ───────────────────────────
# Patroni uses etcd for leader election and distributed configuration.
# Must be a 3-node cluster for quorum (2/3 = majority).
etcd:
hosts: etcd1:2379,etcd2:2379,etcd3:2379
# For single-node dev testing:
# hosts: localhost:2379
# ── Bootstrap ──────────────────────────────────────────────────────
# Initial database creation on first run.
bootstrap:
# Create replicator user (done once on cluster init)
dcs:
postgresql:
parameters:
max_connections: 100
wal_level: replica
max_wal_senders: 10
max_replication_slots: 10
hot_standby: "on"
# Initial users / replication account (runs on leader only)
users:
postgres:
password: "@dmin123"
options:
- SUPERUSER
- LOGIN
replicator:
password: "repl-password-replace-in-prod"
options:
- REPLICATION
# ── Watchdog ───────────────────────────────────────────────────────
# Prevents split-brain by requiring a watchdog device.
# Set to automatic (Patroni will use softdog if available).
# For production: configure a hardware watchdog.
watchdog:
mode: automatic # off | automatic | required
# ── Tags ────────────────────────────────────────────────────────────
# Optional metadata for monitoring / orchestration tools.
tags:
nofailover: false
noloadbalance: false
clonefrom: false
nosync: false
# ─────────────────────────────────────────────────────────────────
# PgBouncer configuration for SSO HA Cluster
# Pool mode: transaction (matches ADR-008 decision)
#
# Connections:
# App → PgBouncer :5432 (external) → HAProxy :5000 (pg_write)
# → HAProxy :5001 (pg_read)
# (read/write routing handled by DbRouter)
#
# Config is passed via environment variables in docker-compose.ha.yml
# (DATABASE_URL, POOL_MODE, MAX_CLIENT_CONN, DEFAULT_POOL_SIZE).
# Additional tuning settings are defined here.
# ─────────────────────────────────────────────────────────────────
[databases]
; DATABASE_URL in docker-compose.ha.yml sets the default connection.
; Additional per-database entries can be added here.
[pgbouncer]
; ── Listening ───────────────────────────────────────────────────────
listen_addr = 0.0.0.0
listen_port = 5432
; ── Pool mode (set via POOL_MODE env var — defaults to 'transaction') ─
; transaction | session | statement
pool_mode = transaction
; ── Connection limits ─────────────────────────────────────────────
max_client_conn = 500
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 5
; ── Server lifetime & idle ──────────────────────────────────────────
server_lifetime = 3600
server_idle_timeout = 600
server_connect_timeout = 15s
server_login_retry = 3s
; ── Autodb ─────────────────────────────────────────────────────────
; PgBouncer creates automatic per-database pools. Set to 0 to disable.
; For SSO HA: we use explicit connection via DATABASE_URL so autodb is off.
max_db_connections = 100
; ── Logging ────────────────────────────────────────────────────────
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
; ── Performance ────────────────────────────────────────────────────
pkt_buf = 4096
max_packet_size = 2147483647
sbuf_loopcnt = 20
; ── Security ──────────────────────────────────────────────────────
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
; In production: use auth_type = scram-sha-256 and TLS
;
\ No newline at end of file
# ───────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
# PgBouncer userlist for SSO # PgBouncer userlist — maps Postgres usernames to password hashes
# Format: "username" "password_hash" # Format (MD5 auth): "username" "md5<password+username_hash>"
# Generate hash: echo -n "password" | pg_md5 #
# ───────────────────────────────────────────── # For dev: using the same credentials as docker-compose env vars.
"postgres" "SCRAM-SHA-256$..." # In production: use scram-sha-256 or Vault to generate this file.
#
# Generate MD5 hash:
# echo -n "password+username" | md5sum | tr -d ' \n' | sed 's/^/md5/'
# Linux/macOS: echo -n "password+username" | md5 -r | sed 's/^/md5/'
#
# Credentials used:
# postgres : @dmin123 → md5hash = md5("@dmin123" + "postgres")
# replicator: repl-password-replace-in-prod → md5hash = md5("repl-password-replace-in-prod" + "replicator")
# ─────────────────────────────────────────────────────────────────
"postgres" "md5$(echo -n '@dmin123postgres' | md5sum | tr -d ' \n')"
"replicator" "md5$(echo -n 'repl-password-replace-in-prodreplicator' | md5sum | tr -d ' \n')"
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
"-----------------DEVELOPMENT------------------": "", "-----------------DEVELOPMENT------------------": "",
"dev": "cross-env NODE_ENV=development nodemon", "dev": "cross-env NODE_ENV=development nodemon",
"start:dev": "npx kill-port 3001 && cross-env NODE_ENV=development nodemon", "start:dev": "npx kill-port 3001 && cross-env NODE_ENV=development nodemon",
"sync:clients": "tsx scripts/sync-rp-clients.ts",
"-----------------BUILDING------------------": "", "-----------------BUILDING------------------": "",
"build": "node lib/build/index.cjs", "build": "node lib/build/index.cjs",
"build:dev": "cross-env NODE_ENV=development npm run build", "build:dev": "cross-env NODE_ENV=development npm run build",
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Đăng nhập — SSO VietProDev</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #4f46e5;
--primary-dark: #4338ca;
--error: #dc2626;
--error-bg: #fef2f2;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-500: #6b7280;
--gray-700: #374151;
--gray-900: #111827;
--radius: 10px;
--shadow: 0 4px 6px -1px rgba(0,0,0,.07), 0 2px 4px -1px rgba(0,0,0,.04);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,.08), 0 4px 6px -2px rgba(0,0,0,.04);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--gray-50);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--gray-900);
}
.card {
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 40px 36px;
width: 100%;
max-width: 420px;
}
.brand-icon {
width: 48px; height: 48px;
background: var(--primary);
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
margin: 0 auto 20px;
font-size: 22px; font-weight: 700; color: #fff;
}
h1 { font-size: 22px; font-weight: 700; text-align: center; margin-bottom: 6px; }
.subtitle { font-size: 14px; color: var(--gray-500); text-align: center; margin-bottom: 28px; }
.field { margin-bottom: 16px; }
label { display: block; font-size: 13px; font-weight: 600; color: var(--gray-700); margin-bottom: 6px; }
input {
width: 100%; padding: 10px 14px;
border: 1.5px solid var(--gray-200); border-radius: 8px;
font-size: 15px; color: var(--gray-900);
background: #fff;
transition: border-color .15s;
outline: none;
}
input:focus { border-color: var(--primary); }
input.error { border-color: var(--error); }
.field-error {
font-size: 12px; color: var(--error); margin-top: 5px; display: none;
}
.field-error.show { display: block; }
.btn {
width: 100%; padding: 11px;
background: var(--primary); color: #fff;
border: none; border-radius: 8px;
font-size: 15px; font-weight: 600;
cursor: pointer;
transition: background .15s, transform .1s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.btn:hover { background: var(--primary-dark); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .6; cursor: not-allowed; }
.alert {
background: var(--error-bg); border: 1px solid #fecaca;
border-radius: 8px; padding: 12px 14px;
font-size: 13px; color: var(--error);
margin-bottom: 16px;
display: none;
}
.alert.show { display: block; }
.spinner {
display: inline-block; width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.fade-in { animation: fadeIn .4s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body>
<div class="card fade-in">
<div class="brand-icon">S</div>
<h1>Đăng nhập</h1>
<p class="subtitle">SSO VietProDev</p>
<div class="alert" id="alert"></div>
<form id="form" novalidate>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="email"
placeholder="you@example.com" required />
<div class="field-error" id="email-error"></div>
</div>
<div class="field">
<label for="password">Mật khẩu</label>
<input type="password" id="password" name="password" autocomplete="current-password"
placeholder="Mật khẩu của bạn" required />
<div class="field-error" id="password-error"></div>
</div>
<button type="submit" class="btn" id="submit-btn">
<span id="btn-text">Đăng nhập</span>
</button>
</form>
</div>
<script>
const form = document.getElementById('form');
const alert = document.getElementById('alert');
const submitBtn = document.getElementById('submit-btn');
const btnText = document.getElementById('btn-text');
function showAlert(msg) {
alert.textContent = msg;
alert.classList.add('show');
}
function hideAlert() {
alert.classList.remove('show');
}
function setLoading(on) {
submitBtn.disabled = on;
btnText.innerHTML = on
? '<span class="spinner"></span> Đang đăng nhập...'
: 'Đăng nhập';
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
// Client-side validation
let valid = true;
const emailErr = document.getElementById('email-error');
const passErr = document.getElementById('password-error');
emailErr.textContent = ''; emailErr.classList.remove('show');
passErr.textContent = ''; passErr.classList.remove('show');
document.getElementById('email').classList.remove('error');
document.getElementById('password').classList.remove('error');
if (!email) {
emailErr.textContent = 'Vui lòng nhập email.'; emailErr.classList.add('show');
document.getElementById('email').classList.add('error'); valid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
emailErr.textContent = 'Email không hợp lệ.'; emailErr.classList.add('show');
document.getElementById('email').classList.add('error'); valid = false;
}
if (!password) {
passErr.textContent = 'Vui lòng nhập mật khẩu.'; passErr.classList.add('show');
document.getElementById('password').classList.add('error'); valid = false;
}
if (!valid) return;
setLoading(true);
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email, password }),
});
const json = await res.json();
if (json.success && json.data?.user) {
// Login OK — store tokens if provided
if (json.data.access_token) {
localStorage.setItem('access_token', json.data.access_token);
}
if (json.data.refresh_token) {
localStorage.setItem('refresh_token', json.data.refresh_token);
}
window.location.href = '/';
} else {
const err = json.errors?.[0];
showAlert(err?.messages?.vi || err?.messages?.en || json.message || 'Đăng nhập thất bại.');
}
} catch {
showAlert('Không thể kết nối đến máy chủ. Vui lòng thử lại.');
} finally {
setLoading(false);
}
});
</script>
</body>
</html>
This diff is collapsed.
/**
* Script: Sync Relying Party clients from .env files into the DB clients table.
*
* Reads .env of each RP project and UPSERTs rows in the `clients` table so that
* the OIDC provider config stays in sync with the RP env vars without manual SQL.
*
* Usage:
* npx tsx scripts/sync-rp-clients.ts
*
* The script is idempotent — running it multiple times is safe.
* It only writes when values differ from what's already in the DB.
*/
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { config as readEnv } from 'dotenv';
import sequelize from '../src/services/database/sequelize/sequelizeService.js';
interface RpConfig {
name: string;
port: number;
dbName: string;
envPath: string;
clientId: string;
clientSecret: string;
redirectUri: string;
postLogoutUri: string;
}
const RP_PROJECTS: RpConfig[] = [
{
name: 'project-a-demo',
port: 4001,
dbName: 'demo_project_a',
envPath: resolve(__dirname, '../../project-a-demo/.env'),
clientId: 'project-a-demo',
clientSecret: '',
redirectUri: 'http://localhost:4001/auth/oidc/callback',
postLogoutUri: 'http://localhost:4001',
},
{
name: 'project-b-demo',
port: 4002,
dbName: 'demo_project_b',
envPath: resolve(__dirname, '../../project-b-demo/.env'),
clientId: 'project-b-demo',
clientSecret: '',
redirectUri: 'http://localhost:4002/auth/oidc/callback',
postLogoutUri: 'http://localhost:4002',
},
];
function loadEnv(path: string): Record<string, string> {
if (!existsSync(path)) {
console.warn(` [WARN] .env not found: ${path}`);
return {};
}
const result = readEnv({ path });
return result.parsed ?? {};
}
async function upsertClient(rp: RpConfig, env: Record<string, string>): Promise<void> {
const clientId = env.SSO_CLIENT_ID ?? rp.clientId;
const clientSecret = env.SSO_CLIENT_SECRET ?? rp.clientSecret;
const redirectUri = env.SSO_REDIRECT_URI ?? rp.redirectUri;
const postLogoutUri = env.SSO_POST_LOGOUT_URI ?? rp.postLogoutUri;
if (!clientId || !clientSecret) {
console.warn(` [SKIP] ${rp.name}: SSO_CLIENT_ID or SSO_CLIENT_SECRET not set in .env`);
return;
}
const [row] = await sequelize.query<{ id: string; client_id: string }>(
`SELECT id, client_id FROM clients WHERE client_id = :clientId`,
{ replacements: { clientId }, type: 'SELECT' as any },
);
const exists = Boolean(row);
const fields = {
redirect_uris: [redirectUri],
post_logout_redirect_uris: [postLogoutUri],
token_endpoint_auth_method: 'client_secret_post',
require_pkce: true,
scopes: ['openid', 'profile', 'email'],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
status: 'active',
};
if (exists) {
// Only update if changed (idempotent)
const [currentRows] = await sequelize.query<Record<string, unknown>>(
`SELECT client_id, redirect_uris, post_logout_redirect_uris, require_pkce, token_endpoint_auth_method
FROM clients WHERE client_id = :clientId`,
{ replacements: { clientId }, type: 'SELECT' as any },
);
const current = currentRows ?? null;
if (current) {
const currentRedirect = Array.isArray(current.redirect_uris) ? current.redirect_uris : [];
const currentPostLogout = Array.isArray(current.post_logout_redirect_uris) ? current.post_logout_redirect_uris : [];
const changed =
JSON.stringify(currentRedirect) !== JSON.stringify(fields.redirect_uris) ||
JSON.stringify(currentPostLogout) !== JSON.stringify(fields.post_logout_redirect_uris) ||
current.require_pkce !== fields.require_pkce ||
current.token_endpoint_auth_method !== fields.token_endpoint_auth_method;
if (changed) {
await sequelize.query(
`UPDATE clients SET redirect_uris = :redirectUris::text[], post_logout_redirect_uris = :postLogoutUris::text[],
token_endpoint_auth_method = :authMethod, require_pkce = :pkce, scopes = :scopes::text[],
grant_types = :grantTypes::text[], response_types = :responseTypes::text[], status = :status, updated_at = NOW()
WHERE client_id = :clientId`,
{
replacements: {
clientId,
redirectUris: JSON.stringify(fields.redirect_uris),
postLogoutUris: JSON.stringify(fields.post_logout_redirect_uris),
authMethod: fields.token_endpoint_auth_method,
pkce: fields.require_pkce,
scopes: JSON.stringify(fields.scopes),
grantTypes: JSON.stringify(fields.grant_types),
responseTypes: JSON.stringify(fields.response_types),
status: fields.status,
},
type: 'UPDATE' as any,
},
);
console.log(` [UPDATE] ${clientId} — fields updated`);
} else {
console.log(` [OK] ${clientId} — already in sync`);
}
}
} else {
// INSERT — generate bcrypt hash for client_secret
// eslint-disable-next-line no-restricted-syntax
const bcrypt = await import('bcryptjs');
const hash = await bcrypt.hash(clientSecret, 10);
await sequelize.query(
`INSERT INTO clients (id, client_id, client_secret_hash, redirect_uris, post_logout_redirect_uris,
token_endpoint_auth_method, require_pkce, scopes, grant_types, response_types, status, created_at, updated_at)
VALUES (gen_random_uuid(), :clientId, :hash, :redirectUris::text[], :postLogoutUris::text[],
'client_secret_post', true, :scopes::text[], :grantTypes::text[], :responseTypes::text[],
'active', NOW(), NOW())
ON CONFLICT (client_id) DO UPDATE SET
redirect_uris = EXCLUDED.redirect_uris,
post_logout_redirect_uris = EXCLUDED.post_logout_redirect_uris,
token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,
require_pkce = EXCLUDED.require_pkce,
scopes = EXCLUDED.scopes,
grant_types = EXCLUDED.grant_types,
response_types = EXCLUDED.response_types,
status = EXCLUDED.status,
updated_at = NOW()`,
{
replacements: {
clientId,
hash,
redirectUris: JSON.stringify(fields.redirect_uris),
postLogoutUris: JSON.stringify(fields.post_logout_redirect_uris),
scopes: JSON.stringify(fields.scopes),
grantTypes: JSON.stringify(fields.grant_types),
responseTypes: JSON.stringify(fields.response_types),
},
type: 'INSERT' as any,
},
);
console.log(` [INSERT] ${clientId} — new row created`);
}
}
async function main(): Promise<void> {
console.log('\n[SyncRPClients] Starting...\n');
await sequelize.authenticate();
console.log('[SyncRPClients] DB connected.\n');
for (const rp of RP_PROJECTS) {
console.log(`Processing: ${rp.name}`);
const env = loadEnv(rp.envPath);
await upsertClient(rp, env);
}
// Verify final state
console.log('\n[SyncRPClients] Final state:\n');
const results = await sequelize.query<Record<string, unknown>>(
`SELECT client_id, redirect_uris::text AS redirect_uris, require_pkce,
token_endpoint_auth_method, status FROM clients
WHERE client_id IN (:ids)`,
{ replacements: { ids: RP_PROJECTS.map((r) => r.clientId) }, type: 'SELECT' as any },
);
const rows = Array.isArray(results) ? results : (results as unknown as { rows?: unknown[] }).rows ?? [];
console.table(rows.map((r) => ({
client_id: String(r.client_id),
redirect_uris: String(r.redirect_uris),
require_pkce: String(r.require_pkce),
auth_method: String(r.token_endpoint_auth_method),
status: String(r.status),
})));
await sequelize.close();
console.log('\n[SyncRPClients] Done.\n');
}
main().catch((err) => {
console.error('[SyncRPClients] Error:', err);
process.exit(1);
});
...@@ -203,7 +203,10 @@ CREATE TABLE IF NOT EXISTS email_verify_tokens ( ...@@ -203,7 +203,10 @@ CREATE TABLE IF NOT EXISTS email_verify_tokens (
token_hash CHAR(64) UNIQUE NOT NULL, token_hash CHAR(64) UNIQUE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL, expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
used_at TIMESTAMP WITH TIME ZONE, used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
oidc_uid VARCHAR(255),
oidc_client_id VARCHAR(255),
oidc_redirect_uri TEXT
); );
...@@ -290,6 +293,7 @@ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at ON password_rese ...@@ -290,6 +293,7 @@ CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_expires_at ON password_rese
CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_user_id ON email_verify_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_user_id ON email_verify_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_expires_at ON email_verify_tokens(expires_at); CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_expires_at ON email_verify_tokens(expires_at);
CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_oidc_uid ON email_verify_tokens(oidc_uid) WHERE oidc_uid IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_auth_audit_user_id ON auth_audit_logs(user_id); CREATE INDEX IF NOT EXISTS idx_auth_audit_user_id ON auth_audit_logs(user_id);
......
-- Migration: 043-add-backchannel-logout-clients.sql
-- Description: Add backchannel_logout_uri and backchannel_logout_session_required columns to clients table
-- Date: 2026-06-22
-- Phase: Phase 2.3 (Cross-Project Silent SSO)
-- Usage: Run via `docker exec sso-postgres psql -U postgres -d sso -f /tmp/migration.sql`
-- Note: Container DB name is `sso` (not `vietprodev_sso`).
-- If your DB is `vietprodev_sso`, replace `-d sso` with `-d vietprodev_sso`.
ALTER TABLE clients
ADD COLUMN IF NOT EXISTS backchannel_logout_uri TEXT,
ADD COLUMN IF NOT EXISTS backchannel_logout_session_required BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_clients_backchannel_logout_uri
ON clients (backchannel_logout_uri)
WHERE backchannel_logout_uri IS NOT NULL;
COMMENT ON COLUMN clients.backchannel_logout_uri IS 'OIDC Back-Channel Logout URI — SSO POSTs a logout_token here when the user logs out.';
COMMENT ON COLUMN clients.backchannel_logout_session_required IS 'Whether the logout_token must include a sid (session ID) claim.';
-- Migration: 044-add-oidc-context-to-email-verify-tokens.sql
-- Description: Add oidc_uid, oidc_client_id, oidc_redirect_uri columns to email_verify_tokens
-- for Flow C — auto-login after email verification
-- Date: 2026-06-25
-- Usage: Run via `docker exec sso-postgres psql -U postgres -d sso -f /tmp/migration.sql`
-- Note: Container DB name is `sso` (not `vietprodev_sso`).
-- If your DB is `vietprodev_sso`, replace `-d sso` with `-d vietprodev_sso`.
ALTER TABLE email_verify_tokens
ADD COLUMN IF NOT EXISTS oidc_uid VARCHAR(255),
ADD COLUMN IF NOT EXISTS oidc_client_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS oidc_redirect_uri TEXT;
CREATE INDEX IF NOT EXISTS idx_email_verify_tokens_oidc_uid
ON email_verify_tokens(oidc_uid)
WHERE oidc_uid IS NOT NULL;
...@@ -32,6 +32,8 @@ INSERT INTO clients ( ...@@ -32,6 +32,8 @@ INSERT INTO clients (
scopes, scopes,
token_endpoint_auth_method, token_endpoint_auth_method,
require_pkce, require_pkce,
backchannel_logout_uri,
backchannel_logout_session_required,
status, status,
created_at created_at
) VALUES ( ) VALUES (
...@@ -40,13 +42,15 @@ INSERT INTO clients ( ...@@ -40,13 +42,15 @@ INSERT INTO clients (
'project-a-demo', 'project-a-demo',
'project-a-demo-secret-123456', 'project-a-demo-secret-123456',
'Project A Demo', 'Project A Demo',
ARRAY['http://localhost:4001/auth/callback']::TEXT[], ARRAY['http://localhost:4001/auth/oidc/callback']::TEXT[],
ARRAY['http://localhost:4001']::TEXT[], ARRAY['http://localhost:4001']::TEXT[],
ARRAY['authorization_code', 'refresh_token']::TEXT[], ARRAY['authorization_code', 'refresh_token']::TEXT[],
ARRAY['code']::TEXT[], ARRAY['code']::TEXT[],
ARRAY['openid', 'profile', 'email']::TEXT[], ARRAY['openid', 'profile', 'email']::TEXT[],
'client_secret_post', 'client_secret_post',
FALSE, TRUE,
'http://localhost:4001/auth/backchannel-logout',
TRUE,
'active', 'active',
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
) )
...@@ -61,6 +65,8 @@ ON CONFLICT (client_id) DO UPDATE SET ...@@ -61,6 +65,8 @@ ON CONFLICT (client_id) DO UPDATE SET
scopes = EXCLUDED.scopes, scopes = EXCLUDED.scopes,
token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method, token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,
require_pkce = EXCLUDED.require_pkce, require_pkce = EXCLUDED.require_pkce,
backchannel_logout_uri = EXCLUDED.backchannel_logout_uri,
backchannel_logout_session_required = EXCLUDED.backchannel_logout_session_required,
status = EXCLUDED.status; status = EXCLUDED.status;
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
...@@ -87,13 +93,13 @@ INSERT INTO clients ( ...@@ -87,13 +93,13 @@ INSERT INTO clients (
'project-b-demo', 'project-b-demo',
'project-b-demo-secret-654321', 'project-b-demo-secret-654321',
'Project B Demo', 'Project B Demo',
ARRAY['http://localhost:4002/auth/callback']::TEXT[], ARRAY['http://localhost:4002/auth/oidc/callback']::TEXT[],
ARRAY['http://localhost:4002']::TEXT[], ARRAY['http://localhost:4002']::TEXT[],
ARRAY['authorization_code', 'refresh_token']::TEXT[], ARRAY['authorization_code', 'refresh_token']::TEXT[],
ARRAY['code']::TEXT[], ARRAY['code']::TEXT[],
ARRAY['openid', 'profile', 'email']::TEXT[], ARRAY['openid', 'profile', 'email']::TEXT[],
'client_secret_post', 'client_secret_post',
FALSE, TRUE,
'active', 'active',
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
) )
......
import { resolve } from 'path';
import { existsSync } from 'fs';
export class OidcConfigService { export class OidcConfigService {
private static readonly KEY_FILE = resolve(process.cwd(), 'config', 'keys', 'keystore.json');
get issuer(): string { get issuer(): string {
return process.env.OIDC_ISSUER ?? 'http://localhost:3001'; return process.env.OIDC_ISSUER ?? 'http://localhost:3001';
} }
...@@ -15,4 +20,13 @@ export class OidcConfigService { ...@@ -15,4 +20,13 @@ export class OidcConfigService {
const keys = process.env.OIDC_COOKIE_KEYS ?? 'dev-cookie-key-minimum-32-chars'; const keys = process.env.OIDC_COOKIE_KEYS ?? 'dev-cookie-key-minimum-32-chars';
return keys.split(',').filter(Boolean); return keys.split(',').filter(Boolean);
} }
/** Path to the JWK keystore file for signing key rotation persistence */
get jwkFile(): string {
return process.env.OIDC_JWK_FILE ?? OidcConfigService.KEY_FILE;
}
get hasJwkFile(): boolean {
return existsSync(this.jwkFile);
}
} }
...@@ -14,8 +14,10 @@ export const FOLDERS = [ ...@@ -14,8 +14,10 @@ export const FOLDERS = [
'config', 'config',
'constants', 'constants',
'controllers', 'controllers',
'contracts',
'dto', 'dto',
'interfaces', 'interfaces',
'jobs',
'middlewares', 'middlewares',
'models', 'models',
'presenters', 'presenters',
......
...@@ -360,6 +360,9 @@ export const VerifyEmailResponseDataSchema = z ...@@ -360,6 +360,9 @@ export const VerifyEmailResponseDataSchema = z
status: z.string(), status: z.string(),
email_verified_at: z.iso.datetime(), email_verified_at: z.iso.datetime(),
message: z.string(), message: z.string(),
auto_login: z.boolean().optional(),
auto_login_error: z.string().optional(),
redirect_to: z.string().optional(),
}) })
.openapi('VerifyEmailResponseData'); .openapi('VerifyEmailResponseData');
......
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { rotateSigningKey, getValidKeys, getActiveKey } from '#jobs/secretRotation';
import { Req, Res } from '#interfaces/IApi';
import verify, { requireAdmin } from '#middlewares/auth';
import { auditAccessLogger } from '#middlewares/audit-access-logger';
import { sendSuccess } from '#utils/responseUtils';
/**
* Admin API: OIDC Signing Key Management
*
* Handles zero-downtime rotation of RSA key pairs used by oidc-provider.
*
* Rotation flow:
* POST /rotate-signing-keys → Add new key, mark old for retirement (24h grace)
* (cron) retireOldKeys → Every hour; removes keys past deadline
*
* Key rotation is NOT auto-scheduled — only triggered manually by admins
* to ensure controlled rollout and avoid mid-flight token invalidation.
*/
export default (_express: Application) => {
return <Resource>{
/**
* POST /api/v1/admin/keys/rotate
*
* Triggers a new signing key rotation:
* 1. Generates a new RSA-2048 key pair
* 2. Adds it to the keystore and marks it active
* 3. Marks the previous key for retirement (grace period: 24h)
* 4. The retireOldKeys cron job removes it after the grace period
*/
post: {
middleware: [verify, requireAdmin, auditAccessLogger('ROTATED')],
handler: async (req: Req, res: Res) => {
try {
const { newKid, retiredKid } = await rotateSigningKey();
return sendSuccess(res, {
newKid,
retiredKid: retiredKid || null,
gracePeriodHours: 24,
message: retiredKid
? `New key ${newKid} activated. Old key ${retiredKid} will be retired after 24h grace period.`
: `First key ${newKid} activated (no previous key to retire).`,
});
} catch (error) {
return res.error(error);
}
},
},
/**
* GET /api/v1/admin/keys
*
* Returns current keystore status: active key + pending-retirement keys.
* Used by admins to audit the signing key state.
*/
get: {
middleware: [verify, requireAdmin],
handler: async (req: Req, res: Res) => {
try {
const activeKey = getActiveKey();
const validKeys = getValidKeys();
return sendSuccess(res, {
activeKid: activeKey?.kid || null,
keys: validKeys.map((k) => ({
kid: k.kid,
alg: k.alg,
use: k.use,
createdAt: k.createdAt,
rotatedAt: k.rotatedAt || null,
isPendingRetirement: k.rotatedAt !== undefined,
})),
});
} catch (error) {
return res.error(error);
}
},
},
};
};
...@@ -14,13 +14,23 @@ import EmailVerificationService from '#services/auth/emailVerificationService'; ...@@ -14,13 +14,23 @@ import EmailVerificationService from '#services/auth/emailVerificationService';
import { AuditLogService } from '#services/audit/auditLogService'; import { AuditLogService } from '#services/audit/auditLogService';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
/** Checks if the request prefers HTML (browser navigation) vs JSON (API client). */
function prefersHtml(req: Req): boolean {
const accept = req.headers.accept || '';
return accept.includes('text/html');
}
/** /**
* GET /api/v1/auth/verify-email?token=... * GET /api/v1/auth/verify-email?token=...
* *
* Public endpoint hit directly from the verification email link. Marks the * Public endpoint hit directly from the verification email link. Marks the
* verification token as used and flips the user to `active` + sets * verification token as used and flips the user to `active` + sets
* `email_verified_at`. Returns JSON describing the outcome so a SPA can show * `email_verified_at`.
* a "verified!" screen and prompt the user to log in. *
* Behaviour:
* - Browser (Accept: text/html) → redirects to /verify-email.html which calls
* this endpoint via fetch(Accept: application/json) and renders the result inline.
* - API client (Accept: application/json or absent) → returns JSON as before.
*/ */
export default (_express: Application) => { export default (_express: Application) => {
return <Resource>{ return <Resource>{
...@@ -29,16 +39,58 @@ export default (_express: Application) => { ...@@ -29,16 +39,58 @@ export default (_express: Application) => {
handler: async (req: Req, res: Res) => { handler: async (req: Req, res: Res) => {
try { try {
const { token } = req.query as unknown as { token: string }; const { token } = req.query as unknown as { token: string };
// ── Browser path: redirect to the standalone HTML page ───────────
if (prefersHtml(req)) {
return res.redirect(302, `/verify-email.html?token=${encodeURIComponent(token)}`);
}
const outcome = await EmailVerificationService.getInstance().verifyToken(String(token)); const outcome = await EmailVerificationService.getInstance().verifyToken(String(token));
if (outcome.kind === 'not_found') { if (outcome.kind === 'not_found') {
throw new GenericError('VERIFICATION_TOKEN_INVALID'); throw new GenericError('VERIFICATION_TOKEN_INVALID');
} }
if (outcome.kind === 'used') { if (outcome.kind === 'used') {
throw new GenericError('VERIFICATION_TOKEN_INVALID', undefined, { // Email đã verified rồi — thử auto-login OIDC nếu có context.
vi: 'Liên kết xác thực đã được sử dụng trước đó. Bạn có thể đăng nhập ngay bây giờ.', if (outcome.tokenRecord.oidc_uid) {
en: 'This verification link was already used. You may log in now.', const loginResult = await EmailVerificationService.autoLoginAfterVerification(
}); outcome.user.id,
outcome.tokenRecord.oidc_uid,
);
if (loginResult.resumed && loginResult.redirectUrl) {
if (prefersHtml(req)) {
return res.redirect(302, loginResult.redirectUrl);
}
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
auto_login: true,
redirect_to: loginResult.redirectUrl,
message: 'Email đã được xác thực trước đó. Đang chuyển hướng đến ứng dụng...',
};
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
}
// OIDC failed — fall through to manual login message
}
// Không có OIDC context hoặc auto-login thất bại → thông báo manual login.
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
auto_login: false,
auto_login_error: outcome.tokenRecord.oidc_uid ? 'ALREADY_USED' : undefined,
message: 'Email đã được xác thực trước đó. Bạn có thể đăng nhập ngay bây giờ.',
};
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
} }
if (outcome.kind === 'expired') { if (outcome.kind === 'expired') {
throw new GenericError('VERIFICATION_TOKEN_EXPIRED'); throw new GenericError('VERIFICATION_TOKEN_EXPIRED');
...@@ -61,6 +113,54 @@ export default (_express: Application) => { ...@@ -61,6 +113,54 @@ export default (_express: Application) => {
severity: 'LOW', severity: 'LOW',
} as any).catch(() => {}); } as any).catch(() => {});
// Attempt OIDC auto-login if the token was issued from an OIDC registration flow.
if (outcome.tokenRecord.oidc_uid) {
const loginResult = await EmailVerificationService.autoLoginAfterVerification(
outcome.user.id,
outcome.tokenRecord.oidc_uid,
);
if (loginResult.resumed && loginResult.redirectUrl) {
// Browser: redirect to the OIDC resume URL so oidc-provider can
// complete the authorization code flow. API clients receive the URL.
if (prefersHtml(req)) {
return res.redirect(302, loginResult.redirectUrl);
}
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
auto_login: true,
redirect_to: loginResult.redirectUrl,
message:
'Email đã được xác thực. Đang chuyển hướng đến ứng dụng...',
};
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
}
// Auto-login failed (interaction expired, invalid state, etc.) —
// fall through to a normal response so the user can log in manually.
const data: VerifyEmailResponseData = {
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 = { const data: VerifyEmailResponseData = {
verified: true, verified: true,
email: outcome.user.email, 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'; ...@@ -6,7 +6,7 @@ import Logger from '#utils/logger';
* Log access to audit logs (meta-audit for compliance) * Log access to audit logs (meta-audit for compliance)
* Enterprise systems need to track who views, exports, or searches audit logs * Enterprise systems need to track who views, exports, or searches audit logs
*/ */
export const auditAccessLogger = (action: 'VIEWED' | 'EXPORTED' | 'SEARCHED') => { export const auditAccessLogger = (action: 'VIEWED' | 'EXPORTED' | 'SEARCHED' | 'ROTATED') => {
return async (req: Request, res: Response, next: NextFunction) => { return async (req: Request, res: Response, next: NextFunction) => {
const originalSend = res.send; const originalSend = res.send;
......
import { Request, Response, NextFunction } from 'express';
import Logger from '#utils/logger';
/**
* HTTPS Redirect Middleware
*
* Redirects all HTTP requests to HTTPS in production/staging environments.
* Should be registered as the FIRST middleware in the Express chain,
* before any other middleware (especially CORS), to avoid processing
* plaintext requests unnecessarily.
*
* This middleware does NOT run in development (NODE_ENV=development).
*
* SECURITY NOTES:
* - Uses 308 (Permanent Redirect) to preserve HTTP method and body
* - Does not redirect WebSocket upgrades (ws://) — handled by the reverse proxy
* - Does not redirect health checks from load balancer probes (if behind a proxy)
* - X-Forwarded-Proto header check supports reverse proxy setups (nginx, cloud LB)
*/
export interface HttpsRedirectOptions {
/** Port to redirect to (default: 443) */
httpsPort?: number | string;
/** Trust X-Forwarded-Proto from these proxy IPs (default: loopback only) */
trustedProxies?: string[];
/** Skip redirect for these paths (e.g. health checks) */
skipPaths?: string[];
}
/**
* Check if the request is already behind a trusted proxy and the frontend
* reports HTTPS (important when running behind an SSL-terminating reverse proxy).
*/
function isSecureBehindProxy(req: Request, trustedProxies: string[]): boolean {
const clientIp = req.ip || '';
const forwardedProto = req.header('X-Forwarded-Proto');
const realSslHeader = req.header('X-Url-Scheme');
// If no proxy headers are set, trust direct connection security
if (!forwardedProto && !realSslHeader) {
return false;
}
// Verify the request came from a trusted proxy before trusting forwarded headers
// eslint-disable-next-line no-restricted-syntax
const isTrusted = trustedProxies.some((proxy) => {
if (proxy === '*') return true;
return clientIp === proxy || clientIp.startsWith(proxy);
});
if (!isTrusted) {
return false;
}
const scheme = realSslHeader || forwardedProto;
return scheme === 'https';
}
export function httpsRedirect(options: HttpsRedirectOptions = {}) {
const {
httpsPort = 443,
trustedProxies = ['127.0.0.1', '::1'],
skipPaths = ['/health', '/healthz', '/ready', '/readyz', '/favicon.ico'],
} = options;
return (req: Request, _res: Response, next: NextFunction): void => {
// Only redirect in non-development environments
// eslint-disable-next-line no-restricted-syntax
if (process.env.NODE_ENV === 'development') {
return next();
}
// Skip specified paths (health checks, etc.)
if (skipPaths.some((p) => req.path === p || req.path.startsWith(p + '/'))) {
return next();
}
// Already HTTPS or localhost (localhost is always HTTP in dev)
if (req.secure || req.protocol === 'https') {
return next();
}
// Trust proxy headers if request comes from a trusted proxy
if (isSecureBehindProxy(req, trustedProxies)) {
return next();
}
// Build redirect URL preserving path and query
const host = req.header('Host') || 'localhost';
const port = typeof httpsPort === 'string' ? parseInt(httpsPort, 10) : httpsPort;
const forwardedPort = req.header('X-Forwarded-Port');
const portStr =
forwardedPort || (port !== 443 ? `:${port}` : '');
const protocol = 'https';
const url = `${protocol}://${host}${portStr}${req.originalUrl}`;
// eslint-disable-next-line no-console
if (process.env.NODE_ENV === 'production') {
Logger.warn(`[HttpsRedirect] Non-HTTPS request: ${req.method} ${req.originalUrl} → redirecting`);
}
// Use 308 to preserve method (POST/PUT won't become GET)
return _res.redirect(308, url);
};
}
import * as Sequelize from 'sequelize'; import * as Sequelize from 'sequelize';
import { DataTypes, Model, Optional } from 'sequelize'; import { DataTypes, Model, Optional } from 'sequelize';
export interface ClientAttributes { export interface ClientAttributes {
id: string; id: string;
app_code: string; app_code: string;
client_id: string; client_id: string;
...@@ -14,6 +14,8 @@ export interface ClientAttributes { ...@@ -14,6 +14,8 @@ export interface ClientAttributes {
scopes: string[]; scopes: string[];
token_endpoint_auth_method: string; token_endpoint_auth_method: string;
require_pkce: boolean; require_pkce: boolean;
backchannel_logout_uri?: string | null;
backchannel_logout_session_required?: boolean;
status: string; status: string;
created_at?: Date; created_at?: Date;
} }
...@@ -29,6 +31,8 @@ export type ClientOptionalAttributes = ...@@ -29,6 +31,8 @@ export type ClientOptionalAttributes =
| 'scopes' | 'scopes'
| 'token_endpoint_auth_method' | 'token_endpoint_auth_method'
| 'require_pkce' | 'require_pkce'
| 'backchannel_logout_uri'
| 'backchannel_logout_session_required'
| 'status' | 'status'
| 'created_at'; | 'created_at';
export type ClientCreationAttributes = Optional<ClientAttributes, ClientOptionalAttributes>; export type ClientCreationAttributes = Optional<ClientAttributes, ClientOptionalAttributes>;
...@@ -45,6 +49,8 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes ...@@ -45,6 +49,8 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes
declare scopes: string[]; declare scopes: string[];
declare token_endpoint_auth_method: string; declare token_endpoint_auth_method: string;
declare require_pkce: boolean; declare require_pkce: boolean;
declare backchannel_logout_uri?: string | null;
declare backchannel_logout_session_required?: boolean;
declare status: string; declare status: string;
declare created_at?: Date; declare created_at?: Date;
...@@ -110,6 +116,15 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes ...@@ -110,6 +116,15 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes
allowNull: false, allowNull: false,
defaultValue: true, defaultValue: true,
}, },
backchannel_logout_uri: {
type: DataTypes.TEXT,
allowNull: true,
},
backchannel_logout_session_required: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
status: { status: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false, allowNull: false,
......
...@@ -8,10 +8,13 @@ export interface EmailVerifyTokenAttributes { ...@@ -8,10 +8,13 @@ export interface EmailVerifyTokenAttributes {
expires_at: Date; expires_at: Date;
used_at?: Date | null; used_at?: Date | null;
created_at?: Date | null; created_at?: Date | null;
oidc_uid?: string | null;
oidc_client_id?: string | null;
oidc_redirect_uri?: string | null;
} }
export type EmailVerifyTokenPk = 'id'; export type EmailVerifyTokenPk = 'id';
export type EmailVerifyTokenId = EmailVerifyToken[EmailVerifyTokenPk]; export type EmailVerifyTokenId = EmailVerifyToken[EmailVerifyTokenPk];
export type EmailVerifyTokenOptionalAttributes = 'id' | 'used_at' | 'created_at'; export type EmailVerifyTokenOptionalAttributes = 'id' | 'used_at' | 'created_at' | 'oidc_uid' | 'oidc_client_id' | 'oidc_redirect_uri';
export type EmailVerifyTokenCreationAttributes = Optional< export type EmailVerifyTokenCreationAttributes = Optional<
EmailVerifyTokenAttributes, EmailVerifyTokenAttributes,
EmailVerifyTokenOptionalAttributes EmailVerifyTokenOptionalAttributes
...@@ -23,6 +26,9 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen ...@@ -23,6 +26,9 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen
declare expires_at: Date; declare expires_at: Date;
declare used_at?: Date | null; declare used_at?: Date | null;
declare created_at?: Date | null; declare created_at?: Date | null;
declare oidc_uid?: string | null;
declare oidc_client_id?: string | null;
declare oidc_redirect_uri?: string | null;
// EmailVerifyToken belongsTo User via user_id // EmailVerifyToken belongsTo User via user_id
declare user: User; declare user: User;
declare getUser: Sequelize.BelongsToGetAssociationMixin<User>; declare getUser: Sequelize.BelongsToGetAssociationMixin<User>;
...@@ -63,6 +69,18 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen ...@@ -63,6 +69,18 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen
allowNull: true, allowNull: true,
defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'), defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),
}, },
oidc_uid: {
type: DataTypes.STRING(255),
allowNull: true,
},
oidc_client_id: {
type: DataTypes.STRING(255),
allowNull: true,
},
oidc_redirect_uri: {
type: DataTypes.TEXT,
allowNull: true,
},
}, },
{ {
sequelize, sequelize,
......
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { User } from '#models/User';
export { User };
...@@ -24,13 +24,55 @@ const router = express.Router(); ...@@ -24,13 +24,55 @@ const router = express.Router();
const MIN_PASSWORD_LENGTH = 12; const MIN_PASSWORD_LENGTH = 12;
const DEFAULT_TTL_HOURS = 24; const DEFAULT_TTL_HOURS = 24;
// GET /oidc/interaction/:uid — render login or consent page // GET /oidc/interaction/:uid — render login or consent page.
// If the authorization request carried `prompt=none` (silent SSO) the
// interaction will only fire when oidc-provider cannot auto-resolve the
// request (no session, or session lacks the required consent). In that
// case we MUST short-circuit to the corresponding OIDC error — rendering
// a login form here would break silent SSO because the user is hidden
// inside an iframe on the RP and cannot interact.
router.get('/:uid', async (req, res) => { router.get('/:uid', async (req, res) => {
try { try {
const details = await OidcService.interactionDetails(req, res); const details = await OidcService.interactionDetails(req, res);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[OIDC] GET /:uid interactionDetails result:', JSON.stringify({ prompt: details.prompt, params: details.params, session: details.session ? { accountId: details.session.accountId } : null })); console.log('[OIDC] GET /:uid interactionDetails result:', JSON.stringify({ prompt: details.prompt, params: details.params, session: details.session ? { accountId: details.session.accountId } : null }));
const promptParam = String(
(details.params as { prompt?: string } | undefined)?.prompt ?? '',
)
.trim()
.toLowerCase();
const isSilentSSO = promptParam.split(/\s+/).includes('none');
// prompt=none short-circuit: surface the RFC 6749 / OIDC spec error
// the RP expects so it can decide whether to fall back to a full
// interactive login. See OIDC Core 1.0 §3.1.2.1 / §3.3.2.1.
if (isSilentSSO) {
const hasSession = Boolean(
(details.session as { accountId?: string } | undefined)?.accountId,
);
// Case 1: silent SSO requested but user has no active session.
if (!hasSession) {
return OidcService.interaction_finished(
req,
res,
{
error: 'login_required',
error_description: 'End-user authentication is required for silent SSO.',
},
{ mergeWithLastSubmission: false },
);
}
// Case 2: session exists but consent is missing — fall through to
// the consent form below so the user can grant it. If they deny
// (POST /:uid/cancel) the error is `access_denied` which the RP
// can interpret as the user actively refusing silent SSO.
// If the user accepts, the next authorization request with
// `prompt=none` will succeed without an interaction.
}
if (details.prompt.name === 'consent') { if (details.prompt.name === 'consent') {
return res.render('consent', { return res.render('consent', {
uid: req.params.uid, uid: req.params.uid,
...@@ -208,8 +250,13 @@ router.post('/:uid/register', async (req, res) => { ...@@ -208,8 +250,13 @@ router.post('/:uid/register', async (req, res) => {
// - The user never received the first email (SMTP failure, spam filter) // - The user never received the first email (SMTP failure, spam filter)
// - The user refreshed the page and tried to register again // - The user refreshed the page and tried to register again
// No new account is created; we reuse the existing pending record. // No new account is created; we reuse the existing pending record.
const oidcParams = await getOidcParamsFromUid(uid);
try { 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() const devVerifyUrl = isDevMode()
? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token)
: null; : null;
...@@ -319,6 +366,8 @@ router.post('/:uid/register', async (req, res) => { ...@@ -319,6 +366,8 @@ router.post('/:uid/register', async (req, res) => {
await logAudit(AUDIT_EVENTS.REGISTER_SUCCESS, createdUserId, req); await logAudit(AUDIT_EVENTS.REGISTER_SUCCESS, createdUserId, req);
const oidcParams = await getOidcParamsFromUid(uid);
let devVerifyUrl: string | null = null; let devVerifyUrl: string | null = null;
if (createdUser) { if (createdUser) {
// Persist pending-email state in Redis so verify-pending works even // Persist pending-email state in Redis so verify-pending works even
...@@ -331,7 +380,11 @@ router.post('/:uid/register', async (req, res) => { ...@@ -331,7 +380,11 @@ router.post('/:uid/register', async (req, res) => {
); );
try { try {
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(createdUser, ttlHours); const issued = await EmailVerificationService.getInstance().sendVerificationEmail(createdUser, ttlHours, {
uid,
clientId: oidcParams.client_id,
redirectUri: oidcParams.redirect_uri,
});
devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null; devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;
} catch (mailErr) { } catch (mailErr) {
// Email transport is best-effort: token row was already inserted // Email transport is best-effort: token row was already inserted
...@@ -429,7 +482,13 @@ router.post('/:uid/resend-verification', async (req, res) => { ...@@ -429,7 +482,13 @@ router.post('/:uid/resend-verification', async (req, res) => {
return renderPending({ email: trimmedEmail, ttlHours }); return renderPending({ email: trimmedEmail, ttlHours });
} }
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existing, ttlHours); const oidcParams = await getOidcParamsFromUid(resolvedUid);
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existing, ttlHours, {
uid: resolvedUid,
clientId: oidcParams.client_id,
redirectUri: oidcParams.redirect_uri,
});
const devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null; const devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;
// Keep Redis fresh with the current uid // Keep Redis fresh with the current uid
...@@ -543,6 +602,25 @@ async function getClientIdFromUid(uid: string): Promise<string> { ...@@ -543,6 +602,25 @@ async function getClientIdFromUid(uid: string): Promise<string> {
} }
} }
/**
* Resolve the full OIDC authorization params for a given interaction uid.
* Used by POST handlers to extract redirect_uri for storing in the
* email verification token so that auto-login can resume the interaction.
*/
async function getOidcParamsFromUid(uid: string): Promise<{ client_id: string; redirect_uri?: string }> {
try {
const provider = OidcService.getInstance();
const interaction = await provider.Interaction.find(uid);
const params = (interaction?.params as { client_id?: string; redirect_uri?: string } | undefined) ?? {};
return { client_id: params?.client_id ?? '', redirect_uri: params?.redirect_uri };
} catch (err) {
Logger.warn(
`[OIDC] getOidcParamsFromUid failed for ${uid}: ${err instanceof Error ? err.message : String(err)}`,
);
return { client_id: '' };
}
}
async function validate_credentials( async function validate_credentials(
email: string, email: string,
password: string, password: string,
......
...@@ -6,6 +6,8 @@ import { OidcConfigService } from '../config/oidcConfigService'; ...@@ -6,6 +6,8 @@ import { OidcConfigService } from '../config/oidcConfigService';
import Logger from '../utils/logger'; import Logger from '../utils/logger';
import { OidcAdapterService } from './oidcAdapterService'; import { OidcAdapterService } from './oidcAdapterService';
import { ClientProvider } from '#providers/ClientProvider'; import { ClientProvider } from '#providers/ClientProvider';
import { User } from '#models/User';
import { rotateSigningKey, retireOldKeys, getActiveKey } from '../jobs/secretRotation';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyOidcProvider = any; type AnyOidcProvider = any;
...@@ -28,10 +30,104 @@ export class OidcService { ...@@ -28,10 +30,104 @@ export class OidcService {
const clients = await this.loadClients(); const clients = await this.loadClients();
// loadExistingGrant is called by oidc-provider AFTER the provider is fully initialized,
// so we look it up via the singleton rather than a closure over a let variable.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadExistingGrant = async (ctx: any) => {
// eslint-disable-next-line no-console
console.log('[loadExistingGrant] called for account:', ctx.oidc.account?.accountId, 'client:', ctx.oidc.client?.clientId);
// First: try to load an existing grant from a prior consent interaction.
const grantId = ctx.oidc.result?.consent?.grantId
|| ctx.oidc.session?.grantIdFor(ctx.oidc.client.clientId);
if (grantId) {
const provider = OidcService.getInstance() as any;
const found = await provider.Grant.find(grantId);
// eslint-disable-next-line no-console
if (found) { console.log('[loadExistingGrant] found existing grant:', grantId); return found; }
}
// Second: for first-party clients, pre-build a Grant with all requested scopes accepted.
// The grantId is stored in the session, so subsequent requests find it via grantIdFor().
// eslint-disable-next-line no-console
console.log('[loadExistingGrant] no existing grant — building new for client:', ctx.oidc.client?.clientId);
const { User } = await import('#models/User.js');
const user = await User.findByPk(ctx.oidc.account.accountId);
if (!user) return undefined;
const requestedScopes = String(ctx.oidc.params?.scope ?? 'openid');
const hasOpenId = ctx.oidc.client?.scopes?.has('openid') ?? false;
const requestedClaims: string[] = hasOpenId
? (ctx.oidc.client?.claims?.filter((c: string) => c !== 'sub') ?? [])
: [];
const provider = OidcService.getInstance() as any;
const grant = new (provider.Grant as any)({
accountId: ctx.oidc.account.accountId,
clientId: ctx.oidc.client.clientId,
});
grant.addOIDCScope(requestedScopes);
const standardClaims = ['name', 'email', 'email_verified', 'preferred_username', 'picture', 'profile'];
const knownClaims = requestedClaims.filter((c: string) => standardClaims.includes(c));
if (knownClaims.length > 0) {
grant.addOIDCClaims(knownClaims);
}
await grant.save();
// eslint-disable-next-line no-console
console.log('[loadExistingGrant] saved new grant with jti:', grant.jti);
// Store grantId in session so the next authorization request finds it immediately.
ctx.oidc.session?.grantIdFor(ctx.oidc.client.clientId, grant.jti);
return grant;
};
// ExternalSigningKey bypasses private-field validation so we can store full JWKs on disk.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { ExternalSigningKey } = (await import('oidc-provider/lib/helpers/keystore.js')) as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class RsaExternalSigningKey extends (ExternalSigningKey as any) {
constructor(private jwk: unknown) {
super();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const j = jwk as any;
this.kid = j.kid;
this.alg = j.alg ?? 'RS256';
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
keyObject(): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (require('crypto') as any).createPrivateKey({ key: this.jwk, format: 'jwk' });
}
async sign(data: ArrayBuffer): Promise<Buffer> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (require('crypto') as any).sign(null, Buffer.from(data), this.keyObject());
}
}
await retireOldKeys();
const activeKey = getActiveKey();
let signingKeys: unknown[];
if (!activeKey) {
await rotateSigningKey();
const fresh = getActiveKey();
if (!fresh) throw new Error('[OIDC] Failed to generate signing key');
signingKeys = [new RsaExternalSigningKey(fresh)];
console.log(`[OIDC] Generated first signing key: kid=${fresh.kid}`);
} else {
signingKeys = [new RsaExternalSigningKey(activeKey)];
console.log(`[OIDC] Loaded signing key from disk: kid=${activeKey.kid}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const provider = new (OidcProvider as any)(this.configService.issuer, { const provider = new (OidcProvider as any)(this.configService.issuer, {
clients, clients,
adapter: (name: string) => adapterService.createAdapter(name), adapter: (name: string) => adapterService.createAdapter(name),
jwks: { keys: signingKeys },
cookies: { cookies: {
keys: this.configService.cookieKeys, keys: this.configService.cookieKeys,
long: { httpOnly: true, sameSite: 'lax' }, long: { httpOnly: true, sameSite: 'lax' },
...@@ -51,6 +147,8 @@ export class OidcService { ...@@ -51,6 +147,8 @@ export class OidcService {
introspection: { enabled: true }, introspection: { enabled: true },
revocation: { enabled: true }, revocation: { enabled: true },
rpInitiatedLogout: { enabled: true }, rpInitiatedLogout: { enabled: true },
backchannelLogout: { enabled: true },
externalSigningSupport: { enabled: true, ack: 'experimental-01' },
}, },
routes: { routes: {
authorization: '/oauth/authorize', authorization: '/oauth/authorize',
...@@ -64,11 +162,12 @@ export class OidcService { ...@@ -64,11 +162,12 @@ export class OidcService {
interactions: { interactions: {
url: (_ctx: unknown, interaction: { uid: string }) => `/oidc/interaction/${interaction.uid}`, url: (_ctx: unknown, interaction: { uid: string }) => `/oidc/interaction/${interaction.uid}`,
}, },
loadExistingGrant,
findAccount: async (_ctx: unknown, sub: string) => { findAccount: async (_ctx: unknown, sub: string) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[OIDC findAccount] called with sub:', sub); console.log('[OIDC findAccount] called with sub:', sub);
const { User } = await import('#models/User'); const { User } = await import('#models/User.js');
const user = await User.findByPk(sub); const user = await User.findByPk(sub);
return { return {
...@@ -176,6 +275,28 @@ export class OidcService { ...@@ -176,6 +275,28 @@ export class OidcService {
} }
}; };
// ── Custom error rendering ────────────────────────────────────────────
// Replaces the oidc-provider default error page (which is unstyled HTML)
// with a branded page that matches the SSO login/consent UI.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(internal as any).renderer = async (ctx: any, out: { error?: string; error_description?: string; status?: number }) => {
try {
const errorViewPath = resolve(__dirname, './views/error.hbs');
const tpl = readFileSync(errorViewPath, 'utf8');
ctx.body = Handlebars.compile(tpl)({
error: out.error ?? 'server_error',
error_description: out.error_description ?? 'An unexpected error occurred. Please try again.',
});
ctx.type = 'html';
if (out.status) ctx.status = out.status;
} catch (renderErr) {
// Fallback to plain text if template fails
console.error('[OIDC renderError] failed to render error template:', renderErr);
ctx.status = out.status ?? 500;
ctx.body = `${out.error ?? 'server_error'}: ${out.error_description ?? 'An unexpected error occurred.'}`;
}
};
return provider; return provider;
} }
......
<!DOCTYPE html>
<html lang="vi" xml:lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSO — Authentication Error</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 1.5rem; }
.card { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 100%; max-width: 480px; }
.icon { font-size: 48px; text-align: center; margin-bottom: 16px; }
h1 { margin-bottom: 8px; color: #1a1a2e; font-size: 22px; }
.error-box { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 12px 16px; border-radius: 8px; margin-bottom: 24px; font-size: 14px; line-height: 1.5; }
.error-code { font-weight: 700; font-size: 13px; letter-spacing: 0.03em; text-transform: uppercase; color: #991b1b; margin-bottom: 4px; }
.description { color: #7f1d1d; margin-top: 4px; }
p { color: #666; font-size: 14px; margin-bottom: 24px; line-height: 1.6; }
a { color: #fff; background: #4f46e5; padding: 12px 24px; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; display: inline-block; text-decoration: none; }
a:hover { background: #4338ca; }
.secondary { background: #fff; color: #4f46e5; border: 1.5px solid #4f46e5; margin-left: 8px; }
.secondary:hover { background: #f0f0ff; }
.footer { margin-top: 20px; text-align: center; font-size: 13px; color: #888; }
.footer a { background: none; color: #4f46e5; padding: 0; font-size: 13px; font-weight: normal; }
.footer a:hover { background: none; text-decoration: underline; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#9888;</div>
<h1>Authentication Error</h1>
<div class="error-box">
<div class="error-code">{{ error }}</div>
{{#if error_description}}<div class="description">{{ error_description }}</div>{{/if}}
</div>
<p>
The SSO authorization could not be completed. This may be due to a configuration issue, an expired request, or a cancelled login.
</p>
<div>
<a href="/auth/oidc/login">Try Again</a>
<a href="/" class="secondary">Back to Home</a>
</div>
<div class="footer">
<a href="/auth/oidc/login">Sign in with a different account</a>
</div>
</div>
</body>
</html>
import { BaseProvider } from '#templates/base/provider'; import { BaseProvider } from '#templates/base/provider';
import { Client } from '#models/Client'; import { Client } from '#models/Client';
export interface OidcProviderClientConfig { export interface OidcProviderClientConfig {
client_id: string; client_id: string;
client_secret?: string; client_secret?: string;
grant_types: string[]; grant_types: string[];
...@@ -11,6 +11,8 @@ export interface OidcProviderClientConfig { ...@@ -11,6 +11,8 @@ export interface OidcProviderClientConfig {
scope: string; scope: string;
token_endpoint_auth_method: string; token_endpoint_auth_method: string;
require_pkce: boolean; require_pkce: boolean;
backchannel_logout_uri?: string;
backchannel_logout_session_required?: boolean;
} }
export class ClientProvider extends BaseProvider<Client> { export class ClientProvider extends BaseProvider<Client> {
...@@ -71,6 +73,14 @@ export class ClientProvider extends BaseProvider<Client> { ...@@ -71,6 +73,14 @@ export class ClientProvider extends BaseProvider<Client> {
config.client_secret = client.client_secret_hash; config.client_secret = client.client_secret_hash;
} }
if (client.backchannel_logout_uri) {
config.backchannel_logout_uri = client.backchannel_logout_uri;
config.backchannel_logout_session_required =
typeof client.backchannel_logout_session_required === 'boolean'
? client.backchannel_logout_session_required
: false;
}
return config; return config;
} }
} }
...@@ -35,6 +35,7 @@ import oidcInteractionsRouter from './oidc/oidcInteractionsController'; ...@@ -35,6 +35,7 @@ import oidcInteractionsRouter from './oidc/oidcInteractionsController';
// Swagger // Swagger
import swaggerUI from 'swagger-ui-express'; import swaggerUI from 'swagger-ui-express';
import Logger, { log } from './utils/logger'; import Logger, { log } from './utils/logger';
import { httpsRedirect } from './middlewares/httpsRedirect';
import sequelize from '#services/database/sequelize/sequelizeService'; import sequelize from '#services/database/sequelize/sequelizeService';
import Config from '#config'; import Config from '#config';
...@@ -134,8 +135,10 @@ const killProcessOnPort = async (port: number): Promise<void> => { ...@@ -134,8 +135,10 @@ const killProcessOnPort = async (port: number): Promise<void> => {
const // Server functions const // Server functions
initServer = async (storagePath: string, env: 'development' | 'staging' | 'production') => { initServer = async (storagePath: string, env: 'development' | 'staging' | 'production') => {
const // Setup constant const // Setup constant
app: Application = express(), app: Application = express();
corsOptions = (function (env: 'development' | 'staging' | 'production') { app.use(httpsRedirect());
const corsOptions = (function (env: 'development' | 'staging' | 'production') {
// Allow all origins if DEV_CORS_DISABLE is set // Allow all origins if DEV_CORS_DISABLE is set
if (Config.cors.devDisable) { if (Config.cors.devDisable) {
return { return {
...@@ -389,6 +392,12 @@ const // Server functions ...@@ -389,6 +392,12 @@ const // Server functions
// before any other routes take over. OIDC routes take priority over REST routes. // before any other routes take over. OIDC routes take priority over REST routes.
app.use('/', oidcRoutes); app.use('/', oidcRoutes);
// Serve public/ UI pages (verify-email, etc.) before autoroutes.
// These are standalone HTML pages — kept in a dedicated folder so the frontend
// team can replace or remove them without touching backend code.
const publicPath = resolve(root, 'public');
app.use(express.static(publicPath));
// Auto import controllers with express-automatic-routes // Auto import controllers with express-automatic-routes
app.all('/api/*', cors(corsOptions)); app.all('/api/*', cors(corsOptions));
autoroutes(app, { dir: resolve(__dirname, './controllers/'), log: false }); autoroutes(app, { dir: resolve(__dirname, './controllers/'), log: false });
......
...@@ -22,9 +22,9 @@ export interface CreateTokenResult { ...@@ -22,9 +22,9 @@ export interface CreateTokenResult {
} }
export type VerifyTokenOutcome = export type VerifyTokenOutcome =
| { kind: 'ok'; user: User } | { kind: 'ok'; user: User; tokenRecord: EmailVerifyToken }
| { kind: 'expired' } | { kind: 'expired' }
| { kind: 'used' } | { kind: 'used'; user: User; tokenRecord: EmailVerifyToken }
| { kind: 'not_found' }; | { kind: 'not_found' };
class EmailVerificationService { class EmailVerificationService {
...@@ -39,7 +39,11 @@ class EmailVerificationService { ...@@ -39,7 +39,11 @@ class EmailVerificationService {
* Create a fresh verification token for a user. Old (unused) tokens for the * Create a fresh verification token for a user. Old (unused) tokens for the
* same user are invalidated so only the most recent email is honoured. * same user are invalidated so only the most recent email is honoured.
*/ */
async createToken(userId: string, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS): Promise<CreateTokenResult> { async createToken(
userId: string,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
oidcContext?: { uid: string; clientId: string; redirectUri?: string } | null,
): Promise<CreateTokenResult> {
const plain = crypto.randomBytes(TOKEN_BYTES).toString('hex'); const plain = crypto.randomBytes(TOKEN_BYTES).toString('hex');
const tokenHash = hashToken(plain); const tokenHash = hashToken(plain);
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000);
...@@ -61,6 +65,9 @@ class EmailVerificationService { ...@@ -61,6 +65,9 @@ class EmailVerificationService {
user_id: userId, user_id: userId,
token_hash: tokenHash, token_hash: tokenHash,
expires_at: expiresAt, expires_at: expiresAt,
oidc_uid: oidcContext?.uid ?? null,
oidc_client_id: oidcContext?.clientId ?? null,
oidc_redirect_uri: oidcContext?.redirectUri ?? null,
}, },
{ transaction: tx } { transaction: tx }
); );
...@@ -83,7 +90,11 @@ class EmailVerificationService { ...@@ -83,7 +90,11 @@ class EmailVerificationService {
}); });
if (!record) return { kind: 'not_found' }; if (!record) return { kind: 'not_found' };
if (record.used_at) return { kind: 'used' }; if (record.used_at) {
const user = await User.findByPk(record.user_id, { transaction: tx });
if (!user) return { kind: 'not_found' };
return { kind: 'used', user, tokenRecord: record };
}
if (record.expires_at.getTime() < Date.now()) return { kind: 'expired' }; if (record.expires_at.getTime() < Date.now()) return { kind: 'expired' };
const user = await User.findByPk(record.user_id, { transaction: tx }); const user = await User.findByPk(record.user_id, { transaction: tx });
...@@ -96,7 +107,7 @@ class EmailVerificationService { ...@@ -96,7 +107,7 @@ class EmailVerificationService {
user.status = 'active'; user.status = 'active';
await user.save({ transaction: tx }); await user.save({ transaction: tx });
return { kind: 'ok', user }; return { kind: 'ok', user, tokenRecord: record };
}); });
} }
...@@ -114,8 +125,12 @@ class EmailVerificationService { ...@@ -114,8 +125,12 @@ class EmailVerificationService {
* configured) the link is appended to `dev-mail.log` so it can be clicked * configured) the link is appended to `dev-mail.log` so it can be clicked
* manually. * manually.
*/ */
async sendVerificationEmail(user: User, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS): Promise<{ token: string; expiresAt: Date }> { async sendVerificationEmail(
const { token, expiresAt } = await this.createToken(user.id, ttlHours); user: User,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
oidcContext?: { uid: string; clientId: string; redirectUri?: string } | null,
): Promise<{ token: string; expiresAt: Date }> {
const { token, expiresAt } = await this.createToken(user.id, ttlHours, oidcContext);
const url = this.buildVerificationUrl(token); const url = this.buildVerificationUrl(token);
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim() || null; const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim() || null;
...@@ -150,6 +165,7 @@ class EmailVerificationService { ...@@ -150,6 +165,7 @@ class EmailVerificationService {
user: User, user: User,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
cooldownSeconds = 60, cooldownSeconds = 60,
oidcContext?: { uid: string; clientId: string; redirectUri?: string } | null,
): Promise<{ expiresAt: Date; mode: 'smtp' | 'fallback' }> { ): Promise<{ expiresAt: Date; mode: 'smtp' | 'fallback' }> {
if (user.email_verified_at) { if (user.email_verified_at) {
throw new GenericError('USER_ALREADY_VERIFIED'); throw new GenericError('USER_ALREADY_VERIFIED');
...@@ -168,14 +184,14 @@ class EmailVerificationService { ...@@ -168,14 +184,14 @@ class EmailVerificationService {
// We can't return the original plaintext token (only the hash is stored). // We can't return the original plaintext token (only the hash is stored).
// Generate a new token to be safe; the previous token is invalidated // Generate a new token to be safe; the previous token is invalidated
// inside createToken(). // inside createToken().
(await this.createToken(user.id, ttlHours)).token, (await this.createToken(user.id, ttlHours, oidcContext)).token,
); );
const result = await this.dispatchMail(user, url, ttlHours); const result = await this.dispatchMail(user, url, ttlHours);
return { expiresAt: recent.expires_at, mode: result }; return { expiresAt: recent.expires_at, mode: result };
} }
} }
const { token, expiresAt } = await this.createToken(user.id, ttlHours); const { token, expiresAt } = await this.createToken(user.id, ttlHours, oidcContext);
const url = this.buildVerificationUrl(token); const url = this.buildVerificationUrl(token);
const mode = await this.dispatchMail(user, url, ttlHours); const mode = await this.dispatchMail(user, url, ttlHours);
return { expiresAt, mode }; return { expiresAt, mode };
...@@ -202,6 +218,92 @@ class EmailVerificationService { ...@@ -202,6 +218,92 @@ class EmailVerificationService {
}); });
return result.mode; return result.mode;
} }
/**
* Complete an OIDC interaction by injecting a `login` result after email
* verification succeeds. Returns the resume URL on success, or an error code
* on failure (expired interaction, invalid state, etc.).
*
* This is called from the verify-email controller after the user record
* has been activated. It bridges the stateless HTTP request (verify-email)
* into oidc-provider's Koa context using only the uid that was stored
* in the verification token.
*/
static async autoLoginAfterVerification(
userId: string,
uid: string,
): Promise<{ resumed: boolean; redirectUrl?: string; error?: string }> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const OidcService = (await import('#oidc/oidcService')).OidcService;
// Build a minimal Express req/res pair containing only the uid in params.
// buildKoaContext uses req.originalUrl to extract the uid for the adapter's
// findByUid lookup — so the URL must contain /oidc/interaction/:uid.
const mockReq = {
params: { uid },
method: 'GET',
originalUrl: `/oidc/interaction/${uid}`,
headers: {},
protocol: 'https',
query: {},
body: {},
cookies: {},
} as unknown as import('express').Request;
const mockRes = {
statusCode: 200,
getHeader: () => '',
setHeader: () => {},
append: () => {},
on: () => mockRes,
end: () => {},
redirect: () => {},
send: () => {},
status: () => mockRes,
} as unknown as import('express').Response;
let ctx: any;
try {
ctx = OidcService.buildKoaContext(mockReq, mockRes);
} catch (err) {
return { resumed: false, error: `KOA_CONTEXT_FAILED: ${(err as Error).message}` };
}
// Validate the interaction still exists and is in a resumable prompt state.
let interactionDetails: any;
try {
interactionDetails = await OidcService.getInstance().interactionDetails(ctx, ctx);
} catch {
return { resumed: false, error: 'INTERACTION_EXPIRED' };
}
const promptName = interactionDetails?.prompt?.name;
if (!['login', 'consent', 'none'].includes(promptName)) {
return { resumed: false, error: `INTERACTION_INVALID_STATE: ${promptName}` };
}
// Inject the login result into the OIDC interaction.
try {
const result = {
login: {
accountId: userId,
remember: true,
ts: Math.floor(Date.now() / 1000),
},
};
const resumeUrl = await OidcService.getInstance().interactionResult(ctx, ctx, result, { mergeWithLastSubmission: false });
// Forward any Set-Cookie written by oidc-provider into the mock response.
const setCookies: string[] = ctx.response.get('set-cookie') || [];
for (const cookie of setCookies) {
if (cookie) mockRes.append('Set-Cookie', cookie);
}
return { resumed: true, redirectUrl: resumeUrl };
} catch (err) {
return { resumed: false, error: `INTERACTION_RESULT_FAILED: ${(err as Error).message}` };
}
}
} }
export default EmailVerificationService; export default EmailVerificationService;
This diff is collapsed.
/**
* 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();
}
}
This diff is collapsed.
This diff is collapsed.
// Ambient declaration for oidc-provider which has no published TypeScript types. // Ambient declaration for oidc-provider which has no published TypeScript types.
// The runtime is fully typed through `any` at call sites — see src/oidc/oidcService.ts. // The runtime is fully typed through `any` at call sites — see src/oidc/oidcService.ts.
declare module 'oidc-provider'; declare module 'oidc-provider';
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 diff is collapsed.
...@@ -46,6 +46,9 @@ ...@@ -46,6 +46,9 @@
"#presenters/*": ["presenters/*"], "#presenters/*": ["presenters/*"],
"#utils/*": ["utils/*"], "#utils/*": ["utils/*"],
"#contracts/*": ["contracts/*"], "#contracts/*": ["contracts/*"],
"#contracts": ["contracts"],
"#jobs/*": ["jobs/*"],
"#jobs": ["jobs"],
"#/*": ["*"], "#/*": ["*"],
"@tests/*": ["../tests/*"], "@tests/*": ["../tests/*"],
"@factories/*": ["../tests/factories/*"], "@factories/*": ["../tests/factories/*"],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment