Commit 09816e1a authored by Vy Nguyễn Minh Khang's avatar Vy Nguyễn Minh Khang

Merge branch 'feat/prepare-to-deploy' into 'develop'

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

See merge request !5
parents a831d8f6 f50da2dc
# ─────────────────────────────────────────────────────────────────
# 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
timeout connect 5s option tcplog
timeout client 60s option dontlognull
timeout server 60s option redispatch # Reassign session if server goes down
retries 3
timeout connect 5s
timeout client 24h
timeout server 24h
# ── Write endpoint (primary) ───────────────── # ── Stats Web UI ───────────────────────────────────────────────────
frontend postgres_write listen stats
bind *:5432 mode http
default_backend postgres_primary bind *:8404
stats enable
stats uri /
stats refresh 10s
# Admin access via Unix socket only (not exposed externally)
stats admin if LOCALHOST
backend postgres_primary # ── PG Write Backend (port 5000) ──────────────────────────────────
option httpchk GET /primary # Routes ONLY to the current primary (master) node.
http-check expect status 200 # HAProxy uses the HTTP health check (option httpchk) on Patroni's REST API.
server pg1 postgres1:5432 check port 8008 inter 5s rise 2 fall 3 # The check probes port 8008 on each server; only the node reporting role=master
server pg2 postgres2:5432 check port 8008 inter 5s rise 2 fall 3 # is the primary. When the primary fails, etcd promotes a replica within ~30s,
server pg3 postgres3:5432 check port 8008 inter 5s rise 2 fall 3 # Patroni REST API reports role=master on the new node, HAProxy routes to it.
#
# Note: The "transparent" option allows real client IP passthrough if PgBouncer
# is configured for it. For standard setups, remove "transparent".
listen pg_write
mode tcp
bind *:5000
option tcplog
balance first # First healthy server wins
option httpchk
http-check expect status 200
default-server inter 5s fall 3 rise 2 on-marked-down shutdown-sessions
# ── Read endpoint (replicas) ─────────────── # All 3 nodes participate — HAProxy sends traffic to the first one that
frontend postgres_read # passes the httpchk (the primary). When primary fails:
bind *:5433 # 1. etcd detects lost leader lock (TTL 30s)
default_backend postgres_replica # 2. etcd election → replica promoted to leader
# 3. Patroni REST API on new leader starts returning role=master
# 4. HAProxy httpchk detects role=master on new node
# 5. Next write connection goes to new primary
server postgres1 postgres1:5432 check port 8008 inter 5s fall 3 rise 2
server postgres2 postgres2:5432 check port 8008 inter 5s fall 3 rise 2
server postgres3 postgres3:5432 check port 8008 inter 5s fall 3 rise 2
backend postgres_replica # ── PG Read Backend (port 5001) ──────────────────────────────────
option httpchk GET /replica # Routes to ALL nodes for read scaling. Load balanced with roundrobin.
http-check expect status 200 # In this 3-node Docker setup, all nodes are replicas of each other.
server pg1 postgres1:5432 check port 8008 inter 5s rise 2 fall 3 # The "pg_write" backend handles primary detection; this backend handles reads.
server pg2 postgres2:5432 check port 8008 inter 5s rise 2 fall 3 listen pg_read
server pg3 postgres3:5432 check port 8008 inter 5s rise 2 fall 3 mode tcp
bind *:5001
option tcplog
balance roundrobin
option httpchk
http-check expect status 200
default-server inter 5s fall 3 rise 2 on-marked-down shutdown-sessions
# ── Stats UI ───────────────────────────────── # All 3 nodes participate — reads are distributed round-robin.
listen stats # For read scaling, route only to replicas by:
bind *:8404 # option tcp-check
mode http # tcp-check connect port 5432
stats enable # # In production with pg_pool-II or pgcat: use their proxy detection
stats uri /stats server postgres1 postgres1:5432 check port 8008 inter 5s fall 3 rise 2
stats refresh 30s server postgres2 postgres2:5432 check port 8008 inter 5s fall 3 rise 2
server postgres3 postgres3:5432 check port 8008 inter 5s fall 3 rise 2
# ─────────────────────────────────────────────────────────────────
# Patroni configuration template for SSO HA Cluster
#
# Patroni reads this from /etc/patroni/patroni.yml (default).
# In docker-compose.ha.yml, config is passed via PATRONI_* env vars
# which Patroni auto-converts. This file serves as documentation
# and can be used when running outside Docker.
#
# To use: copy to /etc/patroni/patroni.yml on each node,
# then run: patronictl -c /etc/patroni/patroni.yml <command>
#
# Environment variable equivalent (docker-compose):
# PATRONI_<SECTION>__<KEY> = value (double underscore = nested key)
# Example: PATRONI_POSTGRESQL__DATA_DIR = /var/lib/postgresql/data
#
# For each node, PATRONI_NAME must be unique (postgres1, postgres2, postgres3).
# For each node, PATRONI_ETCD_HOST must point to the local etcd (etcd1:2379, etcd2:2379, etcd3:2379).
# ─────────────────────────────────────────────────────────────────
scope: sso-postgres
name: postgres1 # Change to postgres2 / postgres3 on other nodes
# ── REST API ────────────────────────────────────────────────────────
# Used by HAProxy for health checks and leader election.
restapi:
listen: 0.0.0.0:8008
connect_address: postgres1:8008 # Change per node
# ── PostgreSQL ──────────────────────────────────────────────────────
postgresql:
listen: 0.0.0.0:5432
connect_address: postgres1:5432 # Change per node
data_dir: /var/lib/postgresql/data
# Replication configuration
authentication:
replication:
username: replicator
password: "repl-password-replace-in-prod"
postgres:
username: postgres
password: "@dmin123"
# Streaming replication parameters
parameters:
wal_level: replica
max_wal_senders: 10
max_replication_slots: 10
hot_standby: "on"
# Connection limits
max_connections: 100
# WAL settings
wal_keep_size: 128MB
# Performance
shared_buffers: 128MB
effective_cache_size: 512MB
maintenance_work_mem: 64MB
checkpoint_completion_target: 0.9
wal_buffers: 4MB
default_statistics_target: 100
random_page_cost: 1.1
effective_io_concurrency: 200
work_mem: 4MB
min_wal_size: 1GB
max_wal_size: 4GB
# WAL archiving (for backup / PITR)
# Enabled in Phase 3d via archive_mode = on
# archive_command: 'cp %p /var/lib/postgresql/wal_archive/%f'
# ── etcd (Distributed Consensus Store) ───────────────────────────
# Patroni uses etcd for leader election and distributed configuration.
# Must be a 3-node cluster for quorum (2/3 = majority).
etcd:
hosts: etcd1:2379,etcd2:2379,etcd3:2379
# For single-node dev testing:
# hosts: localhost:2379
# ── Bootstrap ──────────────────────────────────────────────────────
# Initial database creation on first run.
bootstrap:
# Create replicator user (done once on cluster init)
dcs:
postgresql:
parameters:
max_connections: 100
wal_level: replica
max_wal_senders: 10
max_replication_slots: 10
hot_standby: "on"
# Initial users / replication account (runs on leader only)
users:
postgres:
password: "@dmin123"
options:
- SUPERUSER
- LOGIN
replicator:
password: "repl-password-replace-in-prod"
options:
- REPLICATION
# ── Watchdog ───────────────────────────────────────────────────────
# Prevents split-brain by requiring a watchdog device.
# Set to automatic (Patroni will use softdog if available).
# For production: configure a hardware watchdog.
watchdog:
mode: automatic # off | automatic | required
# ── Tags ────────────────────────────────────────────────────────────
# Optional metadata for monitoring / orchestration tools.
tags:
nofailover: false
noloadbalance: false
clonefrom: false
nosync: false
# ─────────────────────────────────────────────────────────────────
# PgBouncer configuration for SSO HA Cluster
# Pool mode: transaction (matches ADR-008 decision)
#
# Connections:
# App → PgBouncer :5432 (external) → HAProxy :5000 (pg_write)
# → HAProxy :5001 (pg_read)
# (read/write routing handled by DbRouter)
#
# Config is passed via environment variables in docker-compose.ha.yml
# (DATABASE_URL, POOL_MODE, MAX_CLIENT_CONN, DEFAULT_POOL_SIZE).
# Additional tuning settings are defined here.
# ─────────────────────────────────────────────────────────────────
[databases]
; DATABASE_URL in docker-compose.ha.yml sets the default connection.
; Additional per-database entries can be added here.
[pgbouncer]
; ── Listening ───────────────────────────────────────────────────────
listen_addr = 0.0.0.0
listen_port = 5432
; ── Pool mode (set via POOL_MODE env var — defaults to 'transaction') ─
; transaction | session | statement
pool_mode = transaction
; ── Connection limits ─────────────────────────────────────────────
max_client_conn = 500
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5
reserve_pool_timeout = 5
; ── Server lifetime & idle ──────────────────────────────────────────
server_lifetime = 3600
server_idle_timeout = 600
server_connect_timeout = 15s
server_login_retry = 3s
; ── Autodb ─────────────────────────────────────────────────────────
; PgBouncer creates automatic per-database pools. Set to 0 to disable.
; For SSO HA: we use explicit connection via DATABASE_URL so autodb is off.
max_db_connections = 100
; ── Logging ────────────────────────────────────────────────────────
log_connections = 0
log_disconnections = 0
log_pooler_errors = 1
; ── Performance ────────────────────────────────────────────────────
pkt_buf = 4096
max_packet_size = 2147483647
sbuf_loopcnt = 20
; ── Security ──────────────────────────────────────────────────────
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
; In production: use auth_type = scram-sha-256 and TLS
;
\ No newline at end of file
# ───────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────
# PgBouncer userlist for SSO # PgBouncer userlist — maps Postgres usernames to password hashes
# Format: "username" "password_hash" # Format (MD5 auth): "username" "md5<password+username_hash>"
# Generate hash: echo -n "password" | pg_md5 #
# ───────────────────────────────────────────── # For dev: using the same credentials as docker-compose env vars.
"postgres" "SCRAM-SHA-256$..." # In production: use scram-sha-256 or Vault to generate this file.
#
# Generate MD5 hash:
# echo -n "password+username" | md5sum | tr -d ' \n' | sed 's/^/md5/'
# Linux/macOS: echo -n "password+username" | md5 -r | sed 's/^/md5/'
#
# Credentials used:
# postgres : @dmin123 → md5hash = md5("@dmin123" + "postgres")
# replicator: repl-password-replace-in-prod → md5hash = md5("repl-password-replace-in-prod" + "replicator")
# ─────────────────────────────────────────────────────────────────
"postgres" "md5$(echo -n '@dmin123postgres' | md5sum | tr -d ' \n')"
"replicator" "md5$(echo -n 'repl-password-replace-in-prodreplicator' | md5sum | tr -d ' \n')"
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
"-----------------DEVELOPMENT------------------": "", "-----------------DEVELOPMENT------------------": "",
"dev": "cross-env NODE_ENV=development nodemon", "dev": "cross-env NODE_ENV=development nodemon",
"start:dev": "npx kill-port 3001 && cross-env NODE_ENV=development nodemon", "start:dev": "npx kill-port 3001 && cross-env NODE_ENV=development nodemon",
"sync:clients": "tsx scripts/sync-rp-clients.ts",
"-----------------BUILDING------------------": "", "-----------------BUILDING------------------": "",
"build": "node lib/build/index.cjs", "build": "node lib/build/index.cjs",
"build:dev": "cross-env NODE_ENV=development npm run build", "build:dev": "cross-env NODE_ENV=development npm run build",
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Đăng nhập — SSO VietProDev</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #4f46e5;
--primary-dark: #4338ca;
--error: #dc2626;
--error-bg: #fef2f2;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-500: #6b7280;
--gray-700: #374151;
--gray-900: #111827;
--radius: 10px;
--shadow: 0 4px 6px -1px rgba(0,0,0,.07), 0 2px 4px -1px rgba(0,0,0,.04);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,.08), 0 4px 6px -2px rgba(0,0,0,.04);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--gray-50);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: var(--gray-900);
}
.card {
background: #fff;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 40px 36px;
width: 100%;
max-width: 420px;
}
.brand-icon {
width: 48px; height: 48px;
background: var(--primary);
border-radius: 12px;
display: flex; align-items: center; justify-content: center;
margin: 0 auto 20px;
font-size: 22px; font-weight: 700; color: #fff;
}
h1 { font-size: 22px; font-weight: 700; text-align: center; margin-bottom: 6px; }
.subtitle { font-size: 14px; color: var(--gray-500); text-align: center; margin-bottom: 28px; }
.field { margin-bottom: 16px; }
label { display: block; font-size: 13px; font-weight: 600; color: var(--gray-700); margin-bottom: 6px; }
input {
width: 100%; padding: 10px 14px;
border: 1.5px solid var(--gray-200); border-radius: 8px;
font-size: 15px; color: var(--gray-900);
background: #fff;
transition: border-color .15s;
outline: none;
}
input:focus { border-color: var(--primary); }
input.error { border-color: var(--error); }
.field-error {
font-size: 12px; color: var(--error); margin-top: 5px; display: none;
}
.field-error.show { display: block; }
.btn {
width: 100%; padding: 11px;
background: var(--primary); color: #fff;
border: none; border-radius: 8px;
font-size: 15px; font-weight: 600;
cursor: pointer;
transition: background .15s, transform .1s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.btn:hover { background: var(--primary-dark); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .6; cursor: not-allowed; }
.alert {
background: var(--error-bg); border: 1px solid #fecaca;
border-radius: 8px; padding: 12px 14px;
font-size: 13px; color: var(--error);
margin-bottom: 16px;
display: none;
}
.alert.show { display: block; }
.spinner {
display: inline-block; width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.fade-in { animation: fadeIn .4s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
</style>
</head>
<body>
<div class="card fade-in">
<div class="brand-icon">S</div>
<h1>Đăng nhập</h1>
<p class="subtitle">SSO VietProDev</p>
<div class="alert" id="alert"></div>
<form id="form" novalidate>
<div class="field">
<label for="email">Email</label>
<input type="email" id="email" name="email" autocomplete="email"
placeholder="you@example.com" required />
<div class="field-error" id="email-error"></div>
</div>
<div class="field">
<label for="password">Mật khẩu</label>
<input type="password" id="password" name="password" autocomplete="current-password"
placeholder="Mật khẩu của bạn" required />
<div class="field-error" id="password-error"></div>
</div>
<button type="submit" class="btn" id="submit-btn">
<span id="btn-text">Đăng nhập</span>
</button>
</form>
</div>
<script>
const form = document.getElementById('form');
const alert = document.getElementById('alert');
const submitBtn = document.getElementById('submit-btn');
const btnText = document.getElementById('btn-text');
function showAlert(msg) {
alert.textContent = msg;
alert.classList.add('show');
}
function hideAlert() {
alert.classList.remove('show');
}
function setLoading(on) {
submitBtn.disabled = on;
btnText.innerHTML = on
? '<span class="spinner"></span> Đang đăng nhập...'
: 'Đăng nhập';
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
// Client-side validation
let valid = true;
const emailErr = document.getElementById('email-error');
const passErr = document.getElementById('password-error');
emailErr.textContent = ''; emailErr.classList.remove('show');
passErr.textContent = ''; passErr.classList.remove('show');
document.getElementById('email').classList.remove('error');
document.getElementById('password').classList.remove('error');
if (!email) {
emailErr.textContent = 'Vui lòng nhập email.'; emailErr.classList.add('show');
document.getElementById('email').classList.add('error'); valid = false;
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
emailErr.textContent = 'Email không hợp lệ.'; emailErr.classList.add('show');
document.getElementById('email').classList.add('error'); valid = false;
}
if (!password) {
passErr.textContent = 'Vui lòng nhập mật khẩu.'; passErr.classList.add('show');
document.getElementById('password').classList.add('error'); valid = false;
}
if (!valid) return;
setLoading(true);
try {
const res = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ email, password }),
});
const json = await res.json();
if (json.success && json.data?.user) {
// Login OK — store tokens if provided
if (json.data.access_token) {
localStorage.setItem('access_token', json.data.access_token);
}
if (json.data.refresh_token) {
localStorage.setItem('refresh_token', json.data.refresh_token);
}
window.location.href = '/';
} else {
const err = json.errors?.[0];
showAlert(err?.messages?.vi || err?.messages?.en || json.message || 'Đăng nhập thất bại.');
}
} catch {
showAlert('Không thể kết nối đến máy chủ. Vui lòng thử lại.');
} finally {
setLoading(false);
}
});
</script>
</body>
</html>
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,28 +42,32 @@ INSERT INTO clients ( ...@@ -40,28 +42,32 @@ INSERT INTO clients (
'project-a-demo', 'project-a-demo',
'project-a-demo-secret-123456', 'project-a-demo-secret-123456',
'Project A Demo', 'Project A Demo',
ARRAY['http://localhost:4001/auth/callback']::TEXT[], ARRAY['http://localhost:4001/auth/oidc/callback']::TEXT[],
ARRAY['http://localhost:4001']::TEXT[], ARRAY['http://localhost:4001']::TEXT[],
ARRAY['authorization_code', 'refresh_token']::TEXT[], ARRAY['authorization_code', 'refresh_token']::TEXT[],
ARRAY['code']::TEXT[], ARRAY['code']::TEXT[],
ARRAY['openid', 'profile', 'email']::TEXT[], ARRAY['openid', 'profile', 'email']::TEXT[],
'client_secret_post', 'client_secret_post',
FALSE, TRUE,
'http://localhost:4001/auth/backchannel-logout',
TRUE,
'active', 'active',
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
) )
ON CONFLICT (client_id) DO UPDATE SET ON CONFLICT (client_id) DO UPDATE SET
app_code = EXCLUDED.app_code, app_code = EXCLUDED.app_code,
client_secret_hash = EXCLUDED.client_secret_hash, client_secret_hash = EXCLUDED.client_secret_hash,
name = EXCLUDED.name, name = EXCLUDED.name,
redirect_uris = EXCLUDED.redirect_uris, redirect_uris = EXCLUDED.redirect_uris,
post_logout_redirect_uris = EXCLUDED.post_logout_redirect_uris, post_logout_redirect_uris = EXCLUDED.post_logout_redirect_uris,
grant_types = EXCLUDED.grant_types, grant_types = EXCLUDED.grant_types,
response_types = EXCLUDED.response_types, response_types = EXCLUDED.response_types,
scopes = EXCLUDED.scopes, scopes = EXCLUDED.scopes,
token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method, token_endpoint_auth_method = EXCLUDED.token_endpoint_auth_method,
require_pkce = EXCLUDED.require_pkce, require_pkce = EXCLUDED.require_pkce,
status = EXCLUDED.status; backchannel_logout_uri = EXCLUDED.backchannel_logout_uri,
backchannel_logout_session_required = EXCLUDED.backchannel_logout_session_required,
status = EXCLUDED.status;
-- ───────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────
-- Section 2: Project B Demo (port 4002) -- Section 2: Project B Demo (port 4002)
...@@ -87,13 +93,13 @@ INSERT INTO clients ( ...@@ -87,13 +93,13 @@ INSERT INTO clients (
'project-b-demo', 'project-b-demo',
'project-b-demo-secret-654321', 'project-b-demo-secret-654321',
'Project B Demo', 'Project B Demo',
ARRAY['http://localhost:4002/auth/callback']::TEXT[], ARRAY['http://localhost:4002/auth/oidc/callback']::TEXT[],
ARRAY['http://localhost:4002']::TEXT[], ARRAY['http://localhost:4002']::TEXT[],
ARRAY['authorization_code', 'refresh_token']::TEXT[], ARRAY['authorization_code', 'refresh_token']::TEXT[],
ARRAY['code']::TEXT[], ARRAY['code']::TEXT[],
ARRAY['openid', 'profile', 'email']::TEXT[], ARRAY['openid', 'profile', 'email']::TEXT[],
'client_secret_post', 'client_secret_post',
FALSE, TRUE,
'active', 'active',
CURRENT_TIMESTAMP CURRENT_TIMESTAMP
) )
......
import { resolve } from 'path';
import { existsSync } from 'fs';
export class OidcConfigService { export class OidcConfigService {
private static readonly KEY_FILE = resolve(process.cwd(), 'config', 'keys', 'keystore.json');
get issuer(): string { get issuer(): string {
return process.env.OIDC_ISSUER ?? 'http://localhost:3001'; return process.env.OIDC_ISSUER ?? 'http://localhost:3001';
} }
...@@ -15,4 +20,13 @@ export class OidcConfigService { ...@@ -15,4 +20,13 @@ export class OidcConfigService {
const keys = process.env.OIDC_COOKIE_KEYS ?? 'dev-cookie-key-minimum-32-chars'; const keys = process.env.OIDC_COOKIE_KEYS ?? 'dev-cookie-key-minimum-32-chars';
return keys.split(',').filter(Boolean); return keys.split(',').filter(Boolean);
} }
/** Path to the JWK keystore file for signing key rotation persistence */
get jwkFile(): string {
return process.env.OIDC_JWK_FILE ?? OidcConfigService.KEY_FILE;
}
get hasJwkFile(): boolean {
return existsSync(this.jwkFile);
}
} }
...@@ -14,8 +14,10 @@ export const FOLDERS = [ ...@@ -14,8 +14,10 @@ export const FOLDERS = [
'config', 'config',
'constants', 'constants',
'controllers', 'controllers',
'contracts',
'dto', 'dto',
'interfaces', 'interfaces',
'jobs',
'middlewares', 'middlewares',
'models', 'models',
'presenters', 'presenters',
......
...@@ -360,6 +360,9 @@ export const VerifyEmailResponseDataSchema = z ...@@ -360,6 +360,9 @@ export const VerifyEmailResponseDataSchema = z
status: z.string(), status: z.string(),
email_verified_at: z.iso.datetime(), email_verified_at: z.iso.datetime(),
message: z.string(), message: z.string(),
auto_login: z.boolean().optional(),
auto_login_error: z.string().optional(),
redirect_to: z.string().optional(),
}) })
.openapi('VerifyEmailResponseData'); .openapi('VerifyEmailResponseData');
......
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { rotateSigningKey, getValidKeys, getActiveKey } from '#jobs/secretRotation';
import { Req, Res } from '#interfaces/IApi';
import verify, { requireAdmin } from '#middlewares/auth';
import { auditAccessLogger } from '#middlewares/audit-access-logger';
import { sendSuccess } from '#utils/responseUtils';
/**
* Admin API: OIDC Signing Key Management
*
* Handles zero-downtime rotation of RSA key pairs used by oidc-provider.
*
* Rotation flow:
* POST /rotate-signing-keys → Add new key, mark old for retirement (24h grace)
* (cron) retireOldKeys → Every hour; removes keys past deadline
*
* Key rotation is NOT auto-scheduled — only triggered manually by admins
* to ensure controlled rollout and avoid mid-flight token invalidation.
*/
export default (_express: Application) => {
return <Resource>{
/**
* POST /api/v1/admin/keys/rotate
*
* Triggers a new signing key rotation:
* 1. Generates a new RSA-2048 key pair
* 2. Adds it to the keystore and marks it active
* 3. Marks the previous key for retirement (grace period: 24h)
* 4. The retireOldKeys cron job removes it after the grace period
*/
post: {
middleware: [verify, requireAdmin, auditAccessLogger('ROTATED')],
handler: async (req: Req, res: Res) => {
try {
const { newKid, retiredKid } = await rotateSigningKey();
return sendSuccess(res, {
newKid,
retiredKid: retiredKid || null,
gracePeriodHours: 24,
message: retiredKid
? `New key ${newKid} activated. Old key ${retiredKid} will be retired after 24h grace period.`
: `First key ${newKid} activated (no previous key to retire).`,
});
} catch (error) {
return res.error(error);
}
},
},
/**
* GET /api/v1/admin/keys
*
* Returns current keystore status: active key + pending-retirement keys.
* Used by admins to audit the signing key state.
*/
get: {
middleware: [verify, requireAdmin],
handler: async (req: Req, res: Res) => {
try {
const activeKey = getActiveKey();
const validKeys = getValidKeys();
return sendSuccess(res, {
activeKid: activeKey?.kid || null,
keys: validKeys.map((k) => ({
kid: k.kid,
alg: k.alg,
use: k.use,
createdAt: k.createdAt,
rotatedAt: k.rotatedAt || null,
isPendingRetirement: k.rotatedAt !== undefined,
})),
});
} catch (error) {
return res.error(error);
}
},
},
};
};
...@@ -14,13 +14,23 @@ import EmailVerificationService from '#services/auth/emailVerificationService'; ...@@ -14,13 +14,23 @@ import EmailVerificationService from '#services/auth/emailVerificationService';
import { AuditLogService } from '#services/audit/auditLogService'; import { AuditLogService } from '#services/audit/auditLogService';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
/** Checks if the request prefers HTML (browser navigation) vs JSON (API client). */
function prefersHtml(req: Req): boolean {
const accept = req.headers.accept || '';
return accept.includes('text/html');
}
/** /**
* GET /api/v1/auth/verify-email?token=... * GET /api/v1/auth/verify-email?token=...
* *
* Public endpoint hit directly from the verification email link. Marks the * Public endpoint hit directly from the verification email link. Marks the
* verification token as used and flips the user to `active` + sets * verification token as used and flips the user to `active` + sets
* `email_verified_at`. Returns JSON describing the outcome so a SPA can show * `email_verified_at`.
* a "verified!" screen and prompt the user to log in. *
* Behaviour:
* - Browser (Accept: text/html) → redirects to /verify-email.html which calls
* this endpoint via fetch(Accept: application/json) and renders the result inline.
* - API client (Accept: application/json or absent) → returns JSON as before.
*/ */
export default (_express: Application) => { export default (_express: Application) => {
return <Resource>{ return <Resource>{
...@@ -29,38 +39,111 @@ export default (_express: Application) => { ...@@ -29,38 +39,111 @@ export default (_express: Application) => {
handler: async (req: Req, res: Res) => { handler: async (req: Req, res: Res) => {
try { try {
const { token } = req.query as unknown as { token: string }; const { token } = req.query as unknown as { token: string };
// ── Browser path: redirect to the standalone HTML page ───────────
if (prefersHtml(req)) {
return res.redirect(302, `/verify-email.html?token=${encodeURIComponent(token)}`);
}
const outcome = await EmailVerificationService.getInstance().verifyToken(String(token)); const outcome = await EmailVerificationService.getInstance().verifyToken(String(token));
if (outcome.kind === 'not_found') { if (outcome.kind === 'not_found') {
throw new GenericError('VERIFICATION_TOKEN_INVALID'); throw new GenericError('VERIFICATION_TOKEN_INVALID');
} }
if (outcome.kind === 'used') { if (outcome.kind === 'used') {
throw new GenericError('VERIFICATION_TOKEN_INVALID', undefined, { // Email đã verified rồi — thử auto-login OIDC nếu có context.
vi: 'Liên kết xác thực đã được sử dụng trước đó. Bạn có thể đăng nhập ngay bây giờ.', if (outcome.tokenRecord.oidc_uid) {
en: 'This verification link was already used. You may log in now.', const loginResult = await EmailVerificationService.autoLoginAfterVerification(
}); outcome.user.id,
outcome.tokenRecord.oidc_uid,
);
if (loginResult.resumed && loginResult.redirectUrl) {
if (prefersHtml(req)) {
return res.redirect(302, loginResult.redirectUrl);
}
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
auto_login: true,
redirect_to: loginResult.redirectUrl,
message: 'Email đã được xác thực trước đó. Đang chuyển hướng đến ứng dụng...',
};
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
}
// OIDC failed — fall through to manual login message
}
// Không có OIDC context hoặc auto-login thất bại → thông báo manual login.
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
auto_login: false,
auto_login_error: outcome.tokenRecord.oidc_uid ? 'ALREADY_USED' : undefined,
message: 'Email đã được xác thực trước đó. Bạn có thể đăng nhập ngay bây giờ.',
};
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
} }
if (outcome.kind === 'expired') { if (outcome.kind === 'expired') {
throw new GenericError('VERIFICATION_TOKEN_EXPIRED'); throw new GenericError('VERIFICATION_TOKEN_EXPIRED');
} }
// outcome.kind === 'ok' — fire-and-forget audit log // outcome.kind === 'ok' — fire-and-forget audit log
AuditLogService.enqueueSystemAudit({ AuditLogService.enqueueSystemAudit({
requestId: randomUUID(), requestId: randomUUID(),
traceId: randomUUID(), traceId: randomUUID(),
actorId: outcome.user.id, actorId: outcome.user.id,
actorName: actorName:
`${outcome.user.first_name || ''} ${outcome.user.last_name || ''}`.trim() || outcome.user.email, `${outcome.user.first_name || ''} ${outcome.user.last_name || ''}`.trim() || outcome.user.email,
actorEmail: outcome.user.email, actorEmail: outcome.user.email,
actorRole: 'USER', actorRole: 'USER',
action: 'EMAIL_VERIFIED', action: 'EMAIL_VERIFIED',
module: 'AUTH', module: 'AUTH',
entityId: outcome.user.id, entityId: outcome.user.id,
entityType: 'User', entityType: 'User',
description: `Email verified for ${outcome.user.email}`, description: `Email verified for ${outcome.user.email}`,
severity: 'LOW', severity: 'LOW',
} as any).catch(() => {}); } as any).catch(() => {});
// Attempt OIDC auto-login if the token was issued from an OIDC registration flow.
if (outcome.tokenRecord.oidc_uid) {
const loginResult = await EmailVerificationService.autoLoginAfterVerification(
outcome.user.id,
outcome.tokenRecord.oidc_uid,
);
if (loginResult.resumed && loginResult.redirectUrl) {
// Browser: redirect to the OIDC resume URL so oidc-provider can
// complete the authorization code flow. API clients receive the URL.
if (prefersHtml(req)) {
return res.redirect(302, loginResult.redirectUrl);
}
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
auto_login: true,
redirect_to: loginResult.redirectUrl,
message:
'Email đã được xác thực. Đang chuyển hướng đến ứng dụng...',
};
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
}
// Auto-login failed (interaction expired, invalid state, etc.) —
// fall through to a normal response so the user can log in manually.
const data: VerifyEmailResponseData = { const data: VerifyEmailResponseData = {
verified: true, verified: true,
email: outcome.user.email, email: outcome.user.email,
...@@ -68,12 +151,29 @@ export default (_express: Application) => { ...@@ -68,12 +151,29 @@ export default (_express: Application) => {
email_verified_at: outcome.user.email_verified_at email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString() ? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(), : new Date().toISOString(),
auto_login: false,
auto_login_error: loginResult.error ?? 'UNKNOWN_ERROR',
message: message:
'Email đã được xác thực thành công. Bạn có thể đăng nhập ngay bây giờ. / Email verified successfully. You may log in now.', 'Email đã được xác thực nhưng không thể đăng nhập tự động. Bạn có thể đăng nhập thủ công.',
}; };
// Validate data matches the response schema (compile-time + runtime sanity)
VerifyEmailResponseDataSchema.parse(data); VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data); return sendSuccess(res, data);
}
// No OIDC context (REST API registration or no interaction): return normal response.
const data: VerifyEmailResponseData = {
verified: true,
email: outcome.user.email,
status: outcome.user.status ?? 'active',
email_verified_at: outcome.user.email_verified_at
? outcome.user.email_verified_at.toISOString()
: new Date().toISOString(),
message:
'Email đã được xác thực thành công. Bạn có thể đăng nhập ngay bây giờ. / Email verified successfully. You may log in now.',
};
// Validate data matches the response schema (compile-time + runtime sanity)
VerifyEmailResponseDataSchema.parse(data);
return sendSuccess(res, data);
} catch (error) { } catch (error) {
return res.error(error); return res.error(error);
} }
......
import { randomBytes } from 'crypto';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { resolve } from 'path';
import Logger from '#utils/logger';
/**
* OIDC Signing Key Rotation Job
*
* Rotates RSA key pairs used by oidc-provider for signing ID tokens and JWT access tokens.
* Implements zero-downtime rotation using a two-phase approach:
*
* Phase 1 (Day 0): Add new key to keystore
* → New tokens signed with new key, old tokens still verifiable with old key
* Phase 2 (Day 1): Remove old key from keystore
* → All tokens now signed with new key
*
* Key rotation is triggered via a REST endpoint or cron.
* The job stores keys in a file so they persist across restarts.
*
* RATIONALE:
* - oidc-provider generates ephemeral keys by default (lost on restart)
* - Storing keys in Redis allows sharing across multiple instances (HA)
* - This job supports both file-based (single instance) and Redis-based (HA) storage
*/
const KEYS_DIR = resolve(process.cwd(), 'config', 'keys');
const KEY_FILE = resolve(KEYS_DIR, 'keystore.json');
interface KeyEntry {
kid: string;
alg: string;
use: string;
kty: string;
n: string;
e: string;
d: string | undefined;
p: string | undefined;
q: string | undefined;
dp: string | undefined;
dq: string | undefined;
qi: string | undefined;
createdAt: string;
rotatedAt?: string;
}
interface KeystoreData {
keys: KeyEntry[];
activeKid: string;
pendingRetirementKid?: string;
retirementDeadline?: string;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function ensureKeysDir(): void {
if (!existsSync(KEYS_DIR)) {
mkdirSync(KEYS_DIR, { recursive: true });
Logger.info(`[SecretRotation] Created keys directory: ${KEYS_DIR}`);
}
}
function loadKeystore(): KeystoreData {
ensureKeysDir();
if (!existsSync(KEY_FILE)) {
return { keys: [], activeKid: '' } as KeystoreData;
}
try {
return JSON.parse(readFileSync(KEY_FILE, 'utf8')) as KeystoreData;
} catch {
Logger.warn('[SecretRotation] Corrupted keystore — starting fresh');
return { keys: [], activeKid: '' } as KeystoreData;
}
}
function saveKeystore(data: KeystoreData): void {
ensureKeysDir();
writeFileSync(KEY_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
}
/** Generate a new RSA key pair and return it as a JWK */
function generateRsaKeyPair(): KeyEntry {
const { publicKey, privateKey } = require('crypto').generateKeyPairSync('rsa', {
modulusLength: 2048, // 2048-bit minimum; 4096 recommended for production
publicKeyEncoding: { type: 'spki', format: 'jwk' },
privateKeyEncoding: { type: 'pkcs8', format: 'jwk' },
});
const pub = publicKey as KeyEntry;
const priv = privateKey as KeyEntry;
const kid = randomBytes(8).toString('hex');
// JWK private components are strings when generated correctly; assert non-null
return {
kid,
alg: 'RS256',
use: 'sig',
kty: pub.kty,
n: pub.n,
e: pub.e,
d: priv.d ?? undefined,
p: priv.p ?? undefined,
q: priv.q ?? undefined,
dp: priv.dp ?? undefined,
dq: priv.dq ?? undefined,
qi: priv.qi ?? undefined,
createdAt: new Date().toISOString(),
};
}
// ── Core rotation logic ───────────────────────────────────────────────────────
/**
* Rotate to a new signing key.
*
* If no key exists: generates and activates the first key (no old key to retire).
* If a key already exists: adds new key, marks old key for retirement after GRACE_PERIOD.
* The retirement is only executed when retireOldKeys() is called.
*/
export async function rotateSigningKey(): Promise<{ newKid: string; retiredKid: string | undefined }> {
const data = loadKeystore();
const now = new Date();
const GRACE_PERIOD_MS = 24 * 60 * 60 * 1000; // 24 hours
// ── Generate new key ────────────────────────────────────────────────────────
const newKey = generateRsaKeyPair();
const newKid = newKey.kid;
data.keys.push(newKey);
// ── Retire old key (if any) ────────────────────────────────────────────────
let retiredKid: string | undefined;
if (data.activeKid && data.activeKid !== newKid) {
const oldKey = data.keys.find((k) => k.kid === data.activeKid);
if (oldKey) {
oldKey.rotatedAt = now.toISOString();
// Mark as pending retirement — will be removed after grace period
data.pendingRetirementKid = oldKey.kid;
data.retirementDeadline = new Date(now.getTime() + GRACE_PERIOD_MS).toISOString();
Logger.info(`[SecretRotation] Key ${oldKey.kid} marked for retirement at ${data.retirementDeadline}`);
}
retiredKid = data.activeKid;
}
data.activeKid = newKid;
saveKeystore(data);
Logger.info(`[SecretRotation] New signing key active: kid=${newKid}`);
return { newKid, retiredKid };
}
/**
* Remove keys that have passed their retirement deadline.
* Should be called periodically (e.g., every hour via cron).
* Only removes keys that have been rotated AND have passed the grace period.
*/
export async function retireOldKeys(): Promise<string[]> {
const data = loadKeystore();
const now = new Date();
const removed: string[] = [];
if (!data.pendingRetirementKid || !data.retirementDeadline) {
return removed;
}
const deadline = new Date(data.retirementDeadline);
if (now >= deadline) {
const kid = data.pendingRetirementKid;
const idx = data.keys.findIndex((k) => k.kid === kid);
if (idx !== -1) {
data.keys.splice(idx, 1);
removed.push(kid);
Logger.info(`[SecretRotation] Retired old signing key: kid=${kid}`);
}
delete data.pendingRetirementKid;
delete data.retirementDeadline;
saveKeystore(data);
}
return removed;
}
/**
* Get the current active key in JWK format.
* oidc-provider uses this to load the signing key from disk on startup.
*/
export function getActiveKey(): KeyEntry | null {
const data = loadKeystore();
return data.keys.find((k) => k.kid === data.activeKid) || null;
}
/**
* Get all valid keys (active + pending retirement) for the JWKS endpoint.
* Clients use these to verify token signatures.
*/
export function getValidKeys(): KeyEntry[] {
const data = loadKeystore();
return data.keys.filter((k) => {
if (k.kid === data.activeKid) return true;
if (k.kid === data.pendingRetirementKid) return true;
return false;
});
}
/**
* Check if a key is still valid for signature verification.
*/
export function isKeyValid(kid: string): boolean {
return getValidKeys().some((k) => k.kid === kid);
}
// ── Rotation schedule summary ─────────────────────────────────────────────────
//
// Recommended cron setup:
// rotate-signing-key: monthly or on-demand via admin API
// retire-old-keys: every hour (safe to run — only removes keys past deadline)
//
// Flow when rotation is triggered:
// T+0h: rotateSigningKey() adds new key, marks old key for retirement (deadline +24h)
// T+1h: retireOldKeys() runs — old key still valid (deadline not reached)
// T+24h: retireOldKeys() runs — old key now past deadline, removed
//
// This gives all clients 24 hours to fetch the new JWKS and update their verification keys.
...@@ -6,7 +6,7 @@ import Logger from '#utils/logger'; ...@@ -6,7 +6,7 @@ import Logger from '#utils/logger';
* Log access to audit logs (meta-audit for compliance) * Log access to audit logs (meta-audit for compliance)
* Enterprise systems need to track who views, exports, or searches audit logs * Enterprise systems need to track who views, exports, or searches audit logs
*/ */
export const auditAccessLogger = (action: 'VIEWED' | 'EXPORTED' | 'SEARCHED') => { export const auditAccessLogger = (action: 'VIEWED' | 'EXPORTED' | 'SEARCHED' | 'ROTATED') => {
return async (req: Request, res: Response, next: NextFunction) => { return async (req: Request, res: Response, next: NextFunction) => {
const originalSend = res.send; const originalSend = res.send;
......
import { Request, Response, NextFunction } from 'express';
import Logger from '#utils/logger';
/**
* HTTPS Redirect Middleware
*
* Redirects all HTTP requests to HTTPS in production/staging environments.
* Should be registered as the FIRST middleware in the Express chain,
* before any other middleware (especially CORS), to avoid processing
* plaintext requests unnecessarily.
*
* This middleware does NOT run in development (NODE_ENV=development).
*
* SECURITY NOTES:
* - Uses 308 (Permanent Redirect) to preserve HTTP method and body
* - Does not redirect WebSocket upgrades (ws://) — handled by the reverse proxy
* - Does not redirect health checks from load balancer probes (if behind a proxy)
* - X-Forwarded-Proto header check supports reverse proxy setups (nginx, cloud LB)
*/
export interface HttpsRedirectOptions {
/** Port to redirect to (default: 443) */
httpsPort?: number | string;
/** Trust X-Forwarded-Proto from these proxy IPs (default: loopback only) */
trustedProxies?: string[];
/** Skip redirect for these paths (e.g. health checks) */
skipPaths?: string[];
}
/**
* Check if the request is already behind a trusted proxy and the frontend
* reports HTTPS (important when running behind an SSL-terminating reverse proxy).
*/
function isSecureBehindProxy(req: Request, trustedProxies: string[]): boolean {
const clientIp = req.ip || '';
const forwardedProto = req.header('X-Forwarded-Proto');
const realSslHeader = req.header('X-Url-Scheme');
// If no proxy headers are set, trust direct connection security
if (!forwardedProto && !realSslHeader) {
return false;
}
// Verify the request came from a trusted proxy before trusting forwarded headers
// eslint-disable-next-line no-restricted-syntax
const isTrusted = trustedProxies.some((proxy) => {
if (proxy === '*') return true;
return clientIp === proxy || clientIp.startsWith(proxy);
});
if (!isTrusted) {
return false;
}
const scheme = realSslHeader || forwardedProto;
return scheme === 'https';
}
export function httpsRedirect(options: HttpsRedirectOptions = {}) {
const {
httpsPort = 443,
trustedProxies = ['127.0.0.1', '::1'],
skipPaths = ['/health', '/healthz', '/ready', '/readyz', '/favicon.ico'],
} = options;
return (req: Request, _res: Response, next: NextFunction): void => {
// Only redirect in non-development environments
// eslint-disable-next-line no-restricted-syntax
if (process.env.NODE_ENV === 'development') {
return next();
}
// Skip specified paths (health checks, etc.)
if (skipPaths.some((p) => req.path === p || req.path.startsWith(p + '/'))) {
return next();
}
// Already HTTPS or localhost (localhost is always HTTP in dev)
if (req.secure || req.protocol === 'https') {
return next();
}
// Trust proxy headers if request comes from a trusted proxy
if (isSecureBehindProxy(req, trustedProxies)) {
return next();
}
// Build redirect URL preserving path and query
const host = req.header('Host') || 'localhost';
const port = typeof httpsPort === 'string' ? parseInt(httpsPort, 10) : httpsPort;
const forwardedPort = req.header('X-Forwarded-Port');
const portStr =
forwardedPort || (port !== 443 ? `:${port}` : '');
const protocol = 'https';
const url = `${protocol}://${host}${portStr}${req.originalUrl}`;
// eslint-disable-next-line no-console
if (process.env.NODE_ENV === 'production') {
Logger.warn(`[HttpsRedirect] Non-HTTPS request: ${req.method} ${req.originalUrl} → redirecting`);
}
// Use 308 to preserve method (POST/PUT won't become GET)
return _res.redirect(308, url);
};
}
import * as Sequelize from 'sequelize'; import * as Sequelize from 'sequelize';
import { DataTypes, Model, Optional } from 'sequelize'; import { DataTypes, Model, Optional } from 'sequelize';
export interface ClientAttributes { export interface ClientAttributes {
id: string; id: string;
app_code: string; app_code: string;
client_id: string; client_id: string;
...@@ -14,6 +14,8 @@ export interface ClientAttributes { ...@@ -14,6 +14,8 @@ export interface ClientAttributes {
scopes: string[]; scopes: string[];
token_endpoint_auth_method: string; token_endpoint_auth_method: string;
require_pkce: boolean; require_pkce: boolean;
backchannel_logout_uri?: string | null;
backchannel_logout_session_required?: boolean;
status: string; status: string;
created_at?: Date; created_at?: Date;
} }
...@@ -29,6 +31,8 @@ export type ClientOptionalAttributes = ...@@ -29,6 +31,8 @@ export type ClientOptionalAttributes =
| 'scopes' | 'scopes'
| 'token_endpoint_auth_method' | 'token_endpoint_auth_method'
| 'require_pkce' | 'require_pkce'
| 'backchannel_logout_uri'
| 'backchannel_logout_session_required'
| 'status' | 'status'
| 'created_at'; | 'created_at';
export type ClientCreationAttributes = Optional<ClientAttributes, ClientOptionalAttributes>; export type ClientCreationAttributes = Optional<ClientAttributes, ClientOptionalAttributes>;
...@@ -45,6 +49,8 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes ...@@ -45,6 +49,8 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes
declare scopes: string[]; declare scopes: string[];
declare token_endpoint_auth_method: string; declare token_endpoint_auth_method: string;
declare require_pkce: boolean; declare require_pkce: boolean;
declare backchannel_logout_uri?: string | null;
declare backchannel_logout_session_required?: boolean;
declare status: string; declare status: string;
declare created_at?: Date; declare created_at?: Date;
...@@ -110,6 +116,15 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes ...@@ -110,6 +116,15 @@ export class Client extends Model<ClientAttributes> implements ClientAttributes
allowNull: false, allowNull: false,
defaultValue: true, defaultValue: true,
}, },
backchannel_logout_uri: {
type: DataTypes.TEXT,
allowNull: true,
},
backchannel_logout_session_required: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
status: { status: {
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false, allowNull: false,
......
...@@ -8,10 +8,13 @@ export interface EmailVerifyTokenAttributes { ...@@ -8,10 +8,13 @@ export interface EmailVerifyTokenAttributes {
expires_at: Date; expires_at: Date;
used_at?: Date | null; used_at?: Date | null;
created_at?: Date | null; created_at?: Date | null;
oidc_uid?: string | null;
oidc_client_id?: string | null;
oidc_redirect_uri?: string | null;
} }
export type EmailVerifyTokenPk = 'id'; export type EmailVerifyTokenPk = 'id';
export type EmailVerifyTokenId = EmailVerifyToken[EmailVerifyTokenPk]; export type EmailVerifyTokenId = EmailVerifyToken[EmailVerifyTokenPk];
export type EmailVerifyTokenOptionalAttributes = 'id' | 'used_at' | 'created_at'; export type EmailVerifyTokenOptionalAttributes = 'id' | 'used_at' | 'created_at' | 'oidc_uid' | 'oidc_client_id' | 'oidc_redirect_uri';
export type EmailVerifyTokenCreationAttributes = Optional< export type EmailVerifyTokenCreationAttributes = Optional<
EmailVerifyTokenAttributes, EmailVerifyTokenAttributes,
EmailVerifyTokenOptionalAttributes EmailVerifyTokenOptionalAttributes
...@@ -23,6 +26,9 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen ...@@ -23,6 +26,9 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen
declare expires_at: Date; declare expires_at: Date;
declare used_at?: Date | null; declare used_at?: Date | null;
declare created_at?: Date | null; declare created_at?: Date | null;
declare oidc_uid?: string | null;
declare oidc_client_id?: string | null;
declare oidc_redirect_uri?: string | null;
// EmailVerifyToken belongsTo User via user_id // EmailVerifyToken belongsTo User via user_id
declare user: User; declare user: User;
declare getUser: Sequelize.BelongsToGetAssociationMixin<User>; declare getUser: Sequelize.BelongsToGetAssociationMixin<User>;
...@@ -63,6 +69,18 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen ...@@ -63,6 +69,18 @@ export class EmailVerifyToken extends Model<EmailVerifyTokenAttributes> implemen
allowNull: true, allowNull: true,
defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'), defaultValue: Sequelize.Sequelize.literal('CURRENT_TIMESTAMP'),
}, },
oidc_uid: {
type: DataTypes.STRING(255),
allowNull: true,
},
oidc_client_id: {
type: DataTypes.STRING(255),
allowNull: true,
},
oidc_redirect_uri: {
type: DataTypes.TEXT,
allowNull: true,
},
}, },
{ {
sequelize, sequelize,
......
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { User } from '#models/User';
export { User };
...@@ -24,13 +24,55 @@ const router = express.Router(); ...@@ -24,13 +24,55 @@ const router = express.Router();
const MIN_PASSWORD_LENGTH = 12; const MIN_PASSWORD_LENGTH = 12;
const DEFAULT_TTL_HOURS = 24; const DEFAULT_TTL_HOURS = 24;
// GET /oidc/interaction/:uid — render login or consent page // GET /oidc/interaction/:uid — render login or consent page.
// If the authorization request carried `prompt=none` (silent SSO) the
// interaction will only fire when oidc-provider cannot auto-resolve the
// request (no session, or session lacks the required consent). In that
// case we MUST short-circuit to the corresponding OIDC error — rendering
// a login form here would break silent SSO because the user is hidden
// inside an iframe on the RP and cannot interact.
router.get('/:uid', async (req, res) => { router.get('/:uid', async (req, res) => {
try { try {
const details = await OidcService.interactionDetails(req, res); const details = await OidcService.interactionDetails(req, res);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[OIDC] GET /:uid interactionDetails result:', JSON.stringify({ prompt: details.prompt, params: details.params, session: details.session ? { accountId: details.session.accountId } : null })); console.log('[OIDC] GET /:uid interactionDetails result:', JSON.stringify({ prompt: details.prompt, params: details.params, session: details.session ? { accountId: details.session.accountId } : null }));
const promptParam = String(
(details.params as { prompt?: string } | undefined)?.prompt ?? '',
)
.trim()
.toLowerCase();
const isSilentSSO = promptParam.split(/\s+/).includes('none');
// prompt=none short-circuit: surface the RFC 6749 / OIDC spec error
// the RP expects so it can decide whether to fall back to a full
// interactive login. See OIDC Core 1.0 §3.1.2.1 / §3.3.2.1.
if (isSilentSSO) {
const hasSession = Boolean(
(details.session as { accountId?: string } | undefined)?.accountId,
);
// Case 1: silent SSO requested but user has no active session.
if (!hasSession) {
return OidcService.interaction_finished(
req,
res,
{
error: 'login_required',
error_description: 'End-user authentication is required for silent SSO.',
},
{ mergeWithLastSubmission: false },
);
}
// Case 2: session exists but consent is missing — fall through to
// the consent form below so the user can grant it. If they deny
// (POST /:uid/cancel) the error is `access_denied` which the RP
// can interpret as the user actively refusing silent SSO.
// If the user accepts, the next authorization request with
// `prompt=none` will succeed without an interaction.
}
if (details.prompt.name === 'consent') { if (details.prompt.name === 'consent') {
return res.render('consent', { return res.render('consent', {
uid: req.params.uid, uid: req.params.uid,
...@@ -208,8 +250,13 @@ router.post('/:uid/register', async (req, res) => { ...@@ -208,8 +250,13 @@ router.post('/:uid/register', async (req, res) => {
// - The user never received the first email (SMTP failure, spam filter) // - The user never received the first email (SMTP failure, spam filter)
// - The user refreshed the page and tried to register again // - The user refreshed the page and tried to register again
// No new account is created; we reuse the existing pending record. // No new account is created; we reuse the existing pending record.
try { const oidcParams = await getOidcParamsFromUid(uid);
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existingUser, ttlHours); try {
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existingUser, ttlHours, {
uid,
clientId: oidcParams.client_id,
redirectUri: oidcParams.redirect_uri,
});
const devVerifyUrl = isDevMode() const devVerifyUrl = isDevMode()
? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token)
: null; : null;
...@@ -319,6 +366,8 @@ router.post('/:uid/register', async (req, res) => { ...@@ -319,6 +366,8 @@ router.post('/:uid/register', async (req, res) => {
await logAudit(AUDIT_EVENTS.REGISTER_SUCCESS, createdUserId, req); await logAudit(AUDIT_EVENTS.REGISTER_SUCCESS, createdUserId, req);
const oidcParams = await getOidcParamsFromUid(uid);
let devVerifyUrl: string | null = null; let devVerifyUrl: string | null = null;
if (createdUser) { if (createdUser) {
// Persist pending-email state in Redis so verify-pending works even // Persist pending-email state in Redis so verify-pending works even
...@@ -331,7 +380,11 @@ router.post('/:uid/register', async (req, res) => { ...@@ -331,7 +380,11 @@ router.post('/:uid/register', async (req, res) => {
); );
try { try {
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(createdUser, ttlHours); const issued = await EmailVerificationService.getInstance().sendVerificationEmail(createdUser, ttlHours, {
uid,
clientId: oidcParams.client_id,
redirectUri: oidcParams.redirect_uri,
});
devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null; devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;
} catch (mailErr) { } catch (mailErr) {
// Email transport is best-effort: token row was already inserted // Email transport is best-effort: token row was already inserted
...@@ -429,7 +482,13 @@ router.post('/:uid/resend-verification', async (req, res) => { ...@@ -429,7 +482,13 @@ router.post('/:uid/resend-verification', async (req, res) => {
return renderPending({ email: trimmedEmail, ttlHours }); return renderPending({ email: trimmedEmail, ttlHours });
} }
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existing, ttlHours); const oidcParams = await getOidcParamsFromUid(resolvedUid);
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existing, ttlHours, {
uid: resolvedUid,
clientId: oidcParams.client_id,
redirectUri: oidcParams.redirect_uri,
});
const devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null; const devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;
// Keep Redis fresh with the current uid // Keep Redis fresh with the current uid
...@@ -543,6 +602,25 @@ async function getClientIdFromUid(uid: string): Promise<string> { ...@@ -543,6 +602,25 @@ async function getClientIdFromUid(uid: string): Promise<string> {
} }
} }
/**
* Resolve the full OIDC authorization params for a given interaction uid.
* Used by POST handlers to extract redirect_uri for storing in the
* email verification token so that auto-login can resume the interaction.
*/
async function getOidcParamsFromUid(uid: string): Promise<{ client_id: string; redirect_uri?: string }> {
try {
const provider = OidcService.getInstance();
const interaction = await provider.Interaction.find(uid);
const params = (interaction?.params as { client_id?: string; redirect_uri?: string } | undefined) ?? {};
return { client_id: params?.client_id ?? '', redirect_uri: params?.redirect_uri };
} catch (err) {
Logger.warn(
`[OIDC] getOidcParamsFromUid failed for ${uid}: ${err instanceof Error ? err.message : String(err)}`,
);
return { client_id: '' };
}
}
async function validate_credentials( async function validate_credentials(
email: string, email: string,
password: string, password: string,
......
...@@ -6,6 +6,8 @@ import { OidcConfigService } from '../config/oidcConfigService'; ...@@ -6,6 +6,8 @@ import { OidcConfigService } from '../config/oidcConfigService';
import Logger from '../utils/logger'; import Logger from '../utils/logger';
import { OidcAdapterService } from './oidcAdapterService'; import { OidcAdapterService } from './oidcAdapterService';
import { ClientProvider } from '#providers/ClientProvider'; import { ClientProvider } from '#providers/ClientProvider';
import { User } from '#models/User';
import { rotateSigningKey, retireOldKeys, getActiveKey } from '../jobs/secretRotation';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyOidcProvider = any; type AnyOidcProvider = any;
...@@ -28,10 +30,104 @@ export class OidcService { ...@@ -28,10 +30,104 @@ export class OidcService {
const clients = await this.loadClients(); const clients = await this.loadClients();
// loadExistingGrant is called by oidc-provider AFTER the provider is fully initialized,
// so we look it up via the singleton rather than a closure over a let variable.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadExistingGrant = async (ctx: any) => {
// eslint-disable-next-line no-console
console.log('[loadExistingGrant] called for account:', ctx.oidc.account?.accountId, 'client:', ctx.oidc.client?.clientId);
// First: try to load an existing grant from a prior consent interaction.
const grantId = ctx.oidc.result?.consent?.grantId
|| ctx.oidc.session?.grantIdFor(ctx.oidc.client.clientId);
if (grantId) {
const provider = OidcService.getInstance() as any;
const found = await provider.Grant.find(grantId);
// eslint-disable-next-line no-console
if (found) { console.log('[loadExistingGrant] found existing grant:', grantId); return found; }
}
// Second: for first-party clients, pre-build a Grant with all requested scopes accepted.
// The grantId is stored in the session, so subsequent requests find it via grantIdFor().
// eslint-disable-next-line no-console
console.log('[loadExistingGrant] no existing grant — building new for client:', ctx.oidc.client?.clientId);
const { User } = await import('#models/User.js');
const user = await User.findByPk(ctx.oidc.account.accountId);
if (!user) return undefined;
const requestedScopes = String(ctx.oidc.params?.scope ?? 'openid');
const hasOpenId = ctx.oidc.client?.scopes?.has('openid') ?? false;
const requestedClaims: string[] = hasOpenId
? (ctx.oidc.client?.claims?.filter((c: string) => c !== 'sub') ?? [])
: [];
const provider = OidcService.getInstance() as any;
const grant = new (provider.Grant as any)({
accountId: ctx.oidc.account.accountId,
clientId: ctx.oidc.client.clientId,
});
grant.addOIDCScope(requestedScopes);
const standardClaims = ['name', 'email', 'email_verified', 'preferred_username', 'picture', 'profile'];
const knownClaims = requestedClaims.filter((c: string) => standardClaims.includes(c));
if (knownClaims.length > 0) {
grant.addOIDCClaims(knownClaims);
}
await grant.save();
// eslint-disable-next-line no-console
console.log('[loadExistingGrant] saved new grant with jti:', grant.jti);
// Store grantId in session so the next authorization request finds it immediately.
ctx.oidc.session?.grantIdFor(ctx.oidc.client.clientId, grant.jti);
return grant;
};
// ExternalSigningKey bypasses private-field validation so we can store full JWKs on disk.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { ExternalSigningKey } = (await import('oidc-provider/lib/helpers/keystore.js')) as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class RsaExternalSigningKey extends (ExternalSigningKey as any) {
constructor(private jwk: unknown) {
super();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const j = jwk as any;
this.kid = j.kid;
this.alg = j.alg ?? 'RS256';
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
keyObject(): any {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (require('crypto') as any).createPrivateKey({ key: this.jwk, format: 'jwk' });
}
async sign(data: ArrayBuffer): Promise<Buffer> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (require('crypto') as any).sign(null, Buffer.from(data), this.keyObject());
}
}
await retireOldKeys();
const activeKey = getActiveKey();
let signingKeys: unknown[];
if (!activeKey) {
await rotateSigningKey();
const fresh = getActiveKey();
if (!fresh) throw new Error('[OIDC] Failed to generate signing key');
signingKeys = [new RsaExternalSigningKey(fresh)];
console.log(`[OIDC] Generated first signing key: kid=${fresh.kid}`);
} else {
signingKeys = [new RsaExternalSigningKey(activeKey)];
console.log(`[OIDC] Loaded signing key from disk: kid=${activeKey.kid}`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const provider = new (OidcProvider as any)(this.configService.issuer, { const provider = new (OidcProvider as any)(this.configService.issuer, {
clients, clients,
adapter: (name: string) => adapterService.createAdapter(name), adapter: (name: string) => adapterService.createAdapter(name),
jwks: { keys: signingKeys },
cookies: { cookies: {
keys: this.configService.cookieKeys, keys: this.configService.cookieKeys,
long: { httpOnly: true, sameSite: 'lax' }, long: { httpOnly: true, sameSite: 'lax' },
...@@ -51,6 +147,8 @@ export class OidcService { ...@@ -51,6 +147,8 @@ export class OidcService {
introspection: { enabled: true }, introspection: { enabled: true },
revocation: { enabled: true }, revocation: { enabled: true },
rpInitiatedLogout: { enabled: true }, rpInitiatedLogout: { enabled: true },
backchannelLogout: { enabled: true },
externalSigningSupport: { enabled: true, ack: 'experimental-01' },
}, },
routes: { routes: {
authorization: '/oauth/authorize', authorization: '/oauth/authorize',
...@@ -64,11 +162,12 @@ export class OidcService { ...@@ -64,11 +162,12 @@ export class OidcService {
interactions: { interactions: {
url: (_ctx: unknown, interaction: { uid: string }) => `/oidc/interaction/${interaction.uid}`, url: (_ctx: unknown, interaction: { uid: string }) => `/oidc/interaction/${interaction.uid}`,
}, },
loadExistingGrant,
findAccount: async (_ctx: unknown, sub: string) => { findAccount: async (_ctx: unknown, sub: string) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('[OIDC findAccount] called with sub:', sub); console.log('[OIDC findAccount] called with sub:', sub);
const { User } = await import('#models/User'); const { User } = await import('#models/User.js');
const user = await User.findByPk(sub); const user = await User.findByPk(sub);
return { return {
...@@ -176,6 +275,28 @@ export class OidcService { ...@@ -176,6 +275,28 @@ export class OidcService {
} }
}; };
// ── Custom error rendering ────────────────────────────────────────────
// Replaces the oidc-provider default error page (which is unstyled HTML)
// with a branded page that matches the SSO login/consent UI.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(internal as any).renderer = async (ctx: any, out: { error?: string; error_description?: string; status?: number }) => {
try {
const errorViewPath = resolve(__dirname, './views/error.hbs');
const tpl = readFileSync(errorViewPath, 'utf8');
ctx.body = Handlebars.compile(tpl)({
error: out.error ?? 'server_error',
error_description: out.error_description ?? 'An unexpected error occurred. Please try again.',
});
ctx.type = 'html';
if (out.status) ctx.status = out.status;
} catch (renderErr) {
// Fallback to plain text if template fails
console.error('[OIDC renderError] failed to render error template:', renderErr);
ctx.status = out.status ?? 500;
ctx.body = `${out.error ?? 'server_error'}: ${out.error_description ?? 'An unexpected error occurred.'}`;
}
};
return provider; return provider;
} }
......
<!DOCTYPE html>
<html lang="vi" xml:lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSO — Authentication Error</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 1.5rem; }
.card { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 100%; max-width: 480px; }
.icon { font-size: 48px; text-align: center; margin-bottom: 16px; }
h1 { margin-bottom: 8px; color: #1a1a2e; font-size: 22px; }
.error-box { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 12px 16px; border-radius: 8px; margin-bottom: 24px; font-size: 14px; line-height: 1.5; }
.error-code { font-weight: 700; font-size: 13px; letter-spacing: 0.03em; text-transform: uppercase; color: #991b1b; margin-bottom: 4px; }
.description { color: #7f1d1d; margin-top: 4px; }
p { color: #666; font-size: 14px; margin-bottom: 24px; line-height: 1.6; }
a { color: #fff; background: #4f46e5; padding: 12px 24px; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; display: inline-block; text-decoration: none; }
a:hover { background: #4338ca; }
.secondary { background: #fff; color: #4f46e5; border: 1.5px solid #4f46e5; margin-left: 8px; }
.secondary:hover { background: #f0f0ff; }
.footer { margin-top: 20px; text-align: center; font-size: 13px; color: #888; }
.footer a { background: none; color: #4f46e5; padding: 0; font-size: 13px; font-weight: normal; }
.footer a:hover { background: none; text-decoration: underline; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#9888;</div>
<h1>Authentication Error</h1>
<div class="error-box">
<div class="error-code">{{ error }}</div>
{{#if error_description}}<div class="description">{{ error_description }}</div>{{/if}}
</div>
<p>
The SSO authorization could not be completed. This may be due to a configuration issue, an expired request, or a cancelled login.
</p>
<div>
<a href="/auth/oidc/login">Try Again</a>
<a href="/" class="secondary">Back to Home</a>
</div>
<div class="footer">
<a href="/auth/oidc/login">Sign in with a different account</a>
</div>
</div>
</body>
</html>
import { BaseProvider } from '#templates/base/provider'; import { BaseProvider } from '#templates/base/provider';
import { Client } from '#models/Client'; import { Client } from '#models/Client';
export interface OidcProviderClientConfig { export interface OidcProviderClientConfig {
client_id: string; client_id: string;
client_secret?: string; client_secret?: string;
grant_types: string[]; grant_types: string[];
...@@ -11,6 +11,8 @@ export interface OidcProviderClientConfig { ...@@ -11,6 +11,8 @@ export interface OidcProviderClientConfig {
scope: string; scope: string;
token_endpoint_auth_method: string; token_endpoint_auth_method: string;
require_pkce: boolean; require_pkce: boolean;
backchannel_logout_uri?: string;
backchannel_logout_session_required?: boolean;
} }
export class ClientProvider extends BaseProvider<Client> { export class ClientProvider extends BaseProvider<Client> {
...@@ -71,6 +73,14 @@ export class ClientProvider extends BaseProvider<Client> { ...@@ -71,6 +73,14 @@ export class ClientProvider extends BaseProvider<Client> {
config.client_secret = client.client_secret_hash; config.client_secret = client.client_secret_hash;
} }
if (client.backchannel_logout_uri) {
config.backchannel_logout_uri = client.backchannel_logout_uri;
config.backchannel_logout_session_required =
typeof client.backchannel_logout_session_required === 'boolean'
? client.backchannel_logout_session_required
: false;
}
return config; return config;
} }
} }
...@@ -35,6 +35,7 @@ import oidcInteractionsRouter from './oidc/oidcInteractionsController'; ...@@ -35,6 +35,7 @@ import oidcInteractionsRouter from './oidc/oidcInteractionsController';
// Swagger // Swagger
import swaggerUI from 'swagger-ui-express'; import swaggerUI from 'swagger-ui-express';
import Logger, { log } from './utils/logger'; import Logger, { log } from './utils/logger';
import { httpsRedirect } from './middlewares/httpsRedirect';
import sequelize from '#services/database/sequelize/sequelizeService'; import sequelize from '#services/database/sequelize/sequelizeService';
import Config from '#config'; import Config from '#config';
...@@ -134,8 +135,10 @@ const killProcessOnPort = async (port: number): Promise<void> => { ...@@ -134,8 +135,10 @@ const killProcessOnPort = async (port: number): Promise<void> => {
const // Server functions const // Server functions
initServer = async (storagePath: string, env: 'development' | 'staging' | 'production') => { initServer = async (storagePath: string, env: 'development' | 'staging' | 'production') => {
const // Setup constant const // Setup constant
app: Application = express(), app: Application = express();
corsOptions = (function (env: 'development' | 'staging' | 'production') { app.use(httpsRedirect());
const corsOptions = (function (env: 'development' | 'staging' | 'production') {
// Allow all origins if DEV_CORS_DISABLE is set // Allow all origins if DEV_CORS_DISABLE is set
if (Config.cors.devDisable) { if (Config.cors.devDisable) {
return { return {
...@@ -389,6 +392,12 @@ const // Server functions ...@@ -389,6 +392,12 @@ const // Server functions
// before any other routes take over. OIDC routes take priority over REST routes. // before any other routes take over. OIDC routes take priority over REST routes.
app.use('/', oidcRoutes); app.use('/', oidcRoutes);
// Serve public/ UI pages (verify-email, etc.) before autoroutes.
// These are standalone HTML pages — kept in a dedicated folder so the frontend
// team can replace or remove them without touching backend code.
const publicPath = resolve(root, 'public');
app.use(express.static(publicPath));
// Auto import controllers with express-automatic-routes // Auto import controllers with express-automatic-routes
app.all('/api/*', cors(corsOptions)); app.all('/api/*', cors(corsOptions));
autoroutes(app, { dir: resolve(__dirname, './controllers/'), log: false }); autoroutes(app, { dir: resolve(__dirname, './controllers/'), log: false });
......
...@@ -22,9 +22,9 @@ export interface CreateTokenResult { ...@@ -22,9 +22,9 @@ export interface CreateTokenResult {
} }
export type VerifyTokenOutcome = export type VerifyTokenOutcome =
| { kind: 'ok'; user: User } | { kind: 'ok'; user: User; tokenRecord: EmailVerifyToken }
| { kind: 'expired' } | { kind: 'expired' }
| { kind: 'used' } | { kind: 'used'; user: User; tokenRecord: EmailVerifyToken }
| { kind: 'not_found' }; | { kind: 'not_found' };
class EmailVerificationService { class EmailVerificationService {
...@@ -39,7 +39,11 @@ class EmailVerificationService { ...@@ -39,7 +39,11 @@ class EmailVerificationService {
* Create a fresh verification token for a user. Old (unused) tokens for the * Create a fresh verification token for a user. Old (unused) tokens for the
* same user are invalidated so only the most recent email is honoured. * same user are invalidated so only the most recent email is honoured.
*/ */
async createToken(userId: string, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS): Promise<CreateTokenResult> { async createToken(
userId: string,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
oidcContext?: { uid: string; clientId: string; redirectUri?: string } | null,
): Promise<CreateTokenResult> {
const plain = crypto.randomBytes(TOKEN_BYTES).toString('hex'); const plain = crypto.randomBytes(TOKEN_BYTES).toString('hex');
const tokenHash = hashToken(plain); const tokenHash = hashToken(plain);
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000); const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000);
...@@ -61,6 +65,9 @@ class EmailVerificationService { ...@@ -61,6 +65,9 @@ class EmailVerificationService {
user_id: userId, user_id: userId,
token_hash: tokenHash, token_hash: tokenHash,
expires_at: expiresAt, expires_at: expiresAt,
oidc_uid: oidcContext?.uid ?? null,
oidc_client_id: oidcContext?.clientId ?? null,
oidc_redirect_uri: oidcContext?.redirectUri ?? null,
}, },
{ transaction: tx } { transaction: tx }
); );
...@@ -83,7 +90,11 @@ class EmailVerificationService { ...@@ -83,7 +90,11 @@ class EmailVerificationService {
}); });
if (!record) return { kind: 'not_found' }; if (!record) return { kind: 'not_found' };
if (record.used_at) return { kind: 'used' }; if (record.used_at) {
const user = await User.findByPk(record.user_id, { transaction: tx });
if (!user) return { kind: 'not_found' };
return { kind: 'used', user, tokenRecord: record };
}
if (record.expires_at.getTime() < Date.now()) return { kind: 'expired' }; if (record.expires_at.getTime() < Date.now()) return { kind: 'expired' };
const user = await User.findByPk(record.user_id, { transaction: tx }); const user = await User.findByPk(record.user_id, { transaction: tx });
...@@ -96,7 +107,7 @@ class EmailVerificationService { ...@@ -96,7 +107,7 @@ class EmailVerificationService {
user.status = 'active'; user.status = 'active';
await user.save({ transaction: tx }); await user.save({ transaction: tx });
return { kind: 'ok', user }; return { kind: 'ok', user, tokenRecord: record };
}); });
} }
...@@ -114,8 +125,12 @@ class EmailVerificationService { ...@@ -114,8 +125,12 @@ class EmailVerificationService {
* configured) the link is appended to `dev-mail.log` so it can be clicked * configured) the link is appended to `dev-mail.log` so it can be clicked
* manually. * manually.
*/ */
async sendVerificationEmail(user: User, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS): Promise<{ token: string; expiresAt: Date }> { async sendVerificationEmail(
const { token, expiresAt } = await this.createToken(user.id, ttlHours); user: User,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
oidcContext?: { uid: string; clientId: string; redirectUri?: string } | null,
): Promise<{ token: string; expiresAt: Date }> {
const { token, expiresAt } = await this.createToken(user.id, ttlHours, oidcContext);
const url = this.buildVerificationUrl(token); const url = this.buildVerificationUrl(token);
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim() || null; const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim() || null;
...@@ -150,6 +165,7 @@ class EmailVerificationService { ...@@ -150,6 +165,7 @@ class EmailVerificationService {
user: User, user: User,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
cooldownSeconds = 60, cooldownSeconds = 60,
oidcContext?: { uid: string; clientId: string; redirectUri?: string } | null,
): Promise<{ expiresAt: Date; mode: 'smtp' | 'fallback' }> { ): Promise<{ expiresAt: Date; mode: 'smtp' | 'fallback' }> {
if (user.email_verified_at) { if (user.email_verified_at) {
throw new GenericError('USER_ALREADY_VERIFIED'); throw new GenericError('USER_ALREADY_VERIFIED');
...@@ -164,18 +180,18 @@ class EmailVerificationService { ...@@ -164,18 +180,18 @@ class EmailVerificationService {
if (recent) { if (recent) {
// Reuse the existing token if it's still valid — no need to issue a new one // Reuse the existing token if it's still valid — no need to issue a new one
if (recent.expires_at.getTime() - Date.now() > ttlHours * 60 * 60 * 1000 * 0.5) { if (recent.expires_at.getTime() - Date.now() > ttlHours * 60 * 60 * 1000 * 0.5) {
const url = this.buildVerificationUrl( const url = this.buildVerificationUrl(
// We can't return the original plaintext token (only the hash is stored). // We can't return the original plaintext token (only the hash is stored).
// Generate a new token to be safe; the previous token is invalidated // Generate a new token to be safe; the previous token is invalidated
// inside createToken(). // inside createToken().
(await this.createToken(user.id, ttlHours)).token, (await this.createToken(user.id, ttlHours, oidcContext)).token,
); );
const result = await this.dispatchMail(user, url, ttlHours); const result = await this.dispatchMail(user, url, ttlHours);
return { expiresAt: recent.expires_at, mode: result }; return { expiresAt: recent.expires_at, mode: result };
} }
} }
const { token, expiresAt } = await this.createToken(user.id, ttlHours); const { token, expiresAt } = await this.createToken(user.id, ttlHours, oidcContext);
const url = this.buildVerificationUrl(token); const url = this.buildVerificationUrl(token);
const mode = await this.dispatchMail(user, url, ttlHours); const mode = await this.dispatchMail(user, url, ttlHours);
return { expiresAt, mode }; return { expiresAt, mode };
...@@ -202,6 +218,92 @@ class EmailVerificationService { ...@@ -202,6 +218,92 @@ class EmailVerificationService {
}); });
return result.mode; return result.mode;
} }
/**
* Complete an OIDC interaction by injecting a `login` result after email
* verification succeeds. Returns the resume URL on success, or an error code
* on failure (expired interaction, invalid state, etc.).
*
* This is called from the verify-email controller after the user record
* has been activated. It bridges the stateless HTTP request (verify-email)
* into oidc-provider's Koa context using only the uid that was stored
* in the verification token.
*/
static async autoLoginAfterVerification(
userId: string,
uid: string,
): Promise<{ resumed: boolean; redirectUrl?: string; error?: string }> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const OidcService = (await import('#oidc/oidcService')).OidcService;
// Build a minimal Express req/res pair containing only the uid in params.
// buildKoaContext uses req.originalUrl to extract the uid for the adapter's
// findByUid lookup — so the URL must contain /oidc/interaction/:uid.
const mockReq = {
params: { uid },
method: 'GET',
originalUrl: `/oidc/interaction/${uid}`,
headers: {},
protocol: 'https',
query: {},
body: {},
cookies: {},
} as unknown as import('express').Request;
const mockRes = {
statusCode: 200,
getHeader: () => '',
setHeader: () => {},
append: () => {},
on: () => mockRes,
end: () => {},
redirect: () => {},
send: () => {},
status: () => mockRes,
} as unknown as import('express').Response;
let ctx: any;
try {
ctx = OidcService.buildKoaContext(mockReq, mockRes);
} catch (err) {
return { resumed: false, error: `KOA_CONTEXT_FAILED: ${(err as Error).message}` };
}
// Validate the interaction still exists and is in a resumable prompt state.
let interactionDetails: any;
try {
interactionDetails = await OidcService.getInstance().interactionDetails(ctx, ctx);
} catch {
return { resumed: false, error: 'INTERACTION_EXPIRED' };
}
const promptName = interactionDetails?.prompt?.name;
if (!['login', 'consent', 'none'].includes(promptName)) {
return { resumed: false, error: `INTERACTION_INVALID_STATE: ${promptName}` };
}
// Inject the login result into the OIDC interaction.
try {
const result = {
login: {
accountId: userId,
remember: true,
ts: Math.floor(Date.now() / 1000),
},
};
const resumeUrl = await OidcService.getInstance().interactionResult(ctx, ctx, result, { mergeWithLastSubmission: false });
// Forward any Set-Cookie written by oidc-provider into the mock response.
const setCookies: string[] = ctx.response.get('set-cookie') || [];
for (const cookie of setCookies) {
if (cookie) mockRes.append('Set-Cookie', cookie);
}
return { resumed: true, redirectUrl: resumeUrl };
} catch (err) {
return { resumed: false, error: `INTERACTION_RESULT_FAILED: ${(err as Error).message}` };
}
}
} }
export default EmailVerificationService; export default EmailVerificationService;
This diff is collapsed.
This diff is collapsed.
...@@ -43,22 +43,16 @@ export class MultiPoolService { ...@@ -43,22 +43,16 @@ export class MultiPoolService {
this.pools.delete(name); this.pools.delete(name);
} }
const pool = config.pool ?? { max: 20, min: 2, acquire: 30000, idle: 10000, evict: 60000 }; const poolSettings = config.pool ?? { max: 20, min: 2, acquire: 30000, idle: 10000, evict: 60000 };
const sequelize = new Sequelize({ const sequelizeInstance = new Sequelize({
username: config.username, username: config.username,
password: config.password, password: config.password,
database: config.database, database: config.database,
host: config.host, host: config.host,
port: config.port, port: config.port,
dialect: 'postgres', dialect: 'postgres',
pool: { pool: poolSettings,
max: pool.max,
min: pool.min,
acquire: pool.acquire,
idle: pool.idle,
evict: pool.evict,
},
benchmark: true, benchmark: true,
logging(sql, timing) { logging(sql, timing) {
const durationMs = typeof timing === 'number' ? timing : null; const durationMs = typeof timing === 'number' ? timing : null;
...@@ -70,8 +64,8 @@ export class MultiPoolService { ...@@ -70,8 +64,8 @@ export class MultiPoolService {
}, },
}); });
this.pools.set(name, sequelize); this.pools.set(name, sequelizeInstance);
return sequelize; return sequelizeInstance;
} }
static async closeAll(): Promise<void> { static async closeAll(): Promise<void> {
...@@ -90,6 +84,79 @@ export class MultiPoolService { ...@@ -90,6 +84,79 @@ export class MultiPoolService {
} }
} }
/**
* Initialize HA write + read pools from environment variables.
* Used by Phase 3b (HA Cluster) instead of autoLoadPools.
*
* Write pool: reads PG_WRITER_URL (or PG_CONNECTION_STRING) env var.
* Read pool: reads PG_READER_URL env var (falls back to PG_WRITER_URL).
*
* Example:
* PG_WRITER_URL=postgresql://postgres:@dmin123@haproxy:5000/sso
* PG_READER_URL=postgresql://postgres:@dmin123@haproxy:5001/sso
*
* @param poolName Optional custom pool name (default: 'write' and 'read')
*/
static initializeHaPools(poolName?: { write?: string; read?: string }): void {
const writeUrl =
process.env.PG_WRITER_URL ??
process.env.PG_CONNECTION_STRING ??
'postgresql://postgres:@dmin123@localhost:5432/sso';
const readUrl =
process.env.PG_READER_URL ??
process.env.PG_WRITER_URL ??
writeUrl;
const writeName = poolName?.write ?? 'write';
const readName = poolName?.read ?? 'read';
const parseUrl = (url: string) => {
const u = new URL(url);
return {
username: decodeURIComponent(u.username),
password: decodeURIComponent(u.password),
database: u.pathname.replace(/^\//, ''),
host: u.hostname,
port: parseInt(u.port, 10) || 5432,
};
};
const writeConfig = parseUrl(writeUrl);
this.createPool(writeName, {
username: writeConfig.username,
password: writeConfig.password,
database: writeConfig.database,
host: writeConfig.host,
port: writeConfig.port,
});
if (readUrl !== writeUrl) {
const readConfig = parseUrl(readUrl);
this.createPool(readName, {
username: readConfig.username,
password: readConfig.password,
database: readConfig.database,
host: readConfig.host,
port: readConfig.port,
});
} else {
// Same URL for read and write — reuse the write pool
void logger.logAsync(
'warn',
'MultiPoolService',
`PG_READER_URL not set — read pool uses same connection as write pool`,
null,
);
}
void logger.logAsync(
'info',
'MultiPoolService',
`HA pools initialized: write=${writeConfig.host}:${writeConfig.port}, read=${readUrl === writeUrl ? '(shared)' : `${parseUrl(readUrl).host}:${parseUrl(readUrl).port}`}`,
null,
);
}
static async autoLoadPools(): Promise<void> { static async autoLoadPools(): Promise<void> {
// Defensive: project_db_connections is a legacy multi-tenant table not used by SSO. // Defensive: project_db_connections is a legacy multi-tenant table not used by SSO.
// The SSO project does not use dynamic per-project database pools, // The SSO project does not use dynamic per-project database pools,
......
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';
\ No newline at end of file declare module 'oidc-provider/lib/helpers/keystore.js' {
export class ExternalSigningKey {
kid: string;
alg: string;
get use(): string;
get kty(): string;
get n(): string;
get e(): string;
get pub(): string;
keyObject(): unknown;
sign(data: ArrayBuffer): Promise<Buffer>;
}
}
\ No newline at end of file
This 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