Commit 9ee96c4b authored by Lead VietProDev's avatar Lead VietProDev

feat(oidc): email-verified register flow + resend endpoint

Wire the OIDC /interaction/:uid/register handler end-to-end so a brand-
new account is created in 'pending_verification' status, a single-use
token is persisted (SHA-256 hashed) in email_verify_tokens, and a
verify-pending screen is rendered instead of the previous 501 stub.
Auto-login is intentionally skipped: the user must click the link in
the verification email before sign-in is allowed.

Highlights:
- EmailVerificationService: mints tokens, persists hashes, queues mail
  via MailService; dev mode returns the raw verify URL so the flow
  is testable without a working SMTP transport.
- New POST /:uid/resend-verification: re-issues a fresh token; the
  response is identical for known and unknown emails to avoid leaking
  account existence.
- Default 'user' role is assigned inside the same transaction as
  User + UserAuth creation; missing role is non-fatal.
- New view verify-pending.hbs with resend + back-to-sign-in actions.
- register.hbs upgraded to a 2-column responsive grid covering all
  OIDC profile fields (name, phone, gender, address, DOB) plus a
  required terms_accepted checkbox.
- 8 unit tests in tests/unit/services/emailVerification.service.test.ts
  (buildVerificationUrl, createToken hash, verifyToken outcomes,
  resendVerificationEmail guard) - all passing.
- RUN.md gained section 6.5 'Email Verification Flow' with REST/OIDC
  step-by-step, dev mode fallback, curl examples, env vars and a
  manual test checklist.
- PROGRESS.md updated to log Phase 3 re-apply + Phase 4 + Phase 5,
  pre-existing TS errors out of scope, and rollback cheatsheet.

Phase 4b (fix pre-existing TS errors in
src/controllers/api/v1/auth/{verify-email,resend-verification}.ts and
src/server.ts) is documented but deliberately out of scope for this
commit to keep the blast radius small.

Refs: pending_verification, email_verified_at columns,
email_verify_tokens table (migration 040)
Co-authored-by: 's avatarCursor <cursoragent@cursor.com>
parent 1e650959
# Email Verification Feature — Progress Log
> File này là **single source of truth** cho toàn bộ task đang làm dở. Mỗi lần
> chuyển sang đoạn chat mới, hãy `@PROGRESS.md` để mình nắm lại context trong
> 1-2 phút thay vì phải tìm hiểu lại codebase (có thể dẫn đến sửa sai chỗ
> người cũ đã làm đúng).
---
## 1. Bối cảnh dự án
- **Repo**: `c:\VietProDev\sso` (monorepo)
- **Backend**: `c:\VietProDev\sso\sso-vietprodev-backend`
- Express + `express-automatic-routes` + Sequelize + PostgreSQL + Redis + MongoDB (audit)
- OIDC core: `oidc-provider` v9.x
- Validation: Zod (config/contracts) + Joi (request)
- Audit: MongoDB với outbox pattern
- **Templates & docs đã đọc** (BẮT BUỘC tuân thủ):
- `guidelines/VIETPRODEV_GUIDELINES.md` — coding standards, kiến trúc
- `guidelines/CODING_CONVENTION.md` — TypeScript strict, no `any`, kebab-case files
- `guidelines/API_RESPONSE_GUIDE.md` — dùng `GenericError` + `res.error()`
- `guidelines/AUTHENTICATION_GUIDE.md` — dual auth (OIDC + JWT)
- `guidelines/VALIDATION_GUIDE.md` — Zod cho contracts, Joi cho request
- `README.md`, `RUN.md` — cách chạy dự án
### Quy tắc bất di bất dịch
- Path aliases: `#services/`, `#models/`, `#interfaces/`, `#audit/`, `#constants/`, `#config/`, `#templates/`, `#providers/`, `#middlewares/`, `#utils/`, `#contracts/`. **Không** dùng relative `../../` cho cross-folder.
- Dùng `GenericError` từ `#interfaces/error/generic` cho mọi business error.
- Zod schema cho contracts, tham chiếu qua `ApiResponseSchema(...)`.
- Compile check: `cd sso-vietprodev-backend && npx tsc --noEmit` (PHẢI 0 errors).
- Tuyệt đối không tự ý chạy `pnpm db:migrate` (touch DB thật); chỉ gợi ý user chạy.
---
## 2. Tổng quan task
Thêm **email verification** cho cả **REST API** (`/api/v1/auth/*`) và **OIDC flow**
(`/oidc/interaction/:uid/*`). User đăng ký → nhận email chứa link xác thực →
click link → mới có thể login. Hết hạn sau **24h** (config `EMAIL_VERIFICATION_TTL_HOURS`).
### 5 phases
| Phase | Mô tả | Trạng thái |
|------|-------|-----------|
| **1** | Schema (User.address, User.email_verified_at, User.status enum thêm `pending_verification`) + bảng `email_verify_tokens` + `EmailVerificationService` + MailService fallback + email template | ✅ **Hoàn thành** |
| **2** | REST API: `POST /auth/register` (full fields, pending_verification), `POST /auth/login/*` block pending_verification, `GET /auth/verify-email`, `POST /auth/resend-verification` | ✅ **Hoàn thành** (TypeScript clean) |
| **3** | OIDC: form `register.hbs` (full fields), view `check-email.hbs` mới, **KHÔNG auto-login** khi đăng ký, `POST /:uid/resend-verification` cho trang check-email | ⏳ **ĐANG LÀM DỞ** |
| **4** | Test end-to-end: REST qua Swagger + OIDC flow qua project-a-demo | ⏯ Chưa bắt đầu |
| **5** | Update `RUN.md` với register flow docs | ⏯ Chưa bắt đầu |
---
## 3. Trạng thái Phase 1 ✅ (Verified bằng codegraph)
### 3.1 Bảng `email_verify_tokens` — migration 040
- File: `sql/migrations/040_add_email_verification.up.sql` (đã apply)
- Cột: `id (UUID PK)`, `user_id (UUID FK→users)`, `token_hash (CHAR(64) unique)`,
`expires_at`, `used_at`, `created_at`.
- Indexes: PK, unique `token_hash`, `idx_email_verify_tokens_expires_at`,
`idx_email_verify_tokens_user_id`.
- Model: `src/models/EmailVerifyToken.ts` (Sequelize, table `email_verify_tokens`).
- User model có thêm cột: `address (string|null)`, `email_verified_at (Date|null)`,
`status` enum thêm `pending_verification`.
### 3.2 `EmailVerificationService`
File: `src/services/auth/emailVerificationService.ts`
```ts
class EmailVerificationService {
static getInstance(): EmailVerificationService
async createToken(userId, ttlHours): Promise<{ token, expiresAt }>
// SHA-256 hash, insert vào email_verify_tokens,
// invalidate token cũ cùng user (nếu chưa dùng) để chống spam.
async verifyToken(plain): Promise<{ kind: 'ok'|'used'|'expired'|'not_found', user? }>
// Transaction: set used_at + user.email_verified_at + user.status='active'.
buildVerificationUrl(plain): string
// `${Config.server.backendUrl}/api/v1/auth/verify-email?token=${enc}`
async sendVerificationEmail(user, ttlHours): Promise<{ token, expiresAt }>
// Tạo token, build URL, render template, gọi MailService.
async resendVerificationEmail(user, ttlHours, cooldownSeconds=60): Promise<{ expiresAt, mode: 'smtp'|'fallback' }>
// Throw USER_ALREADY_VERIFIED nếu đã verify.
// Cooldown: nếu có token cũ chưa dùng + còn hạn > 50% TTL → tạo token mới + gửi lại.
private async dispatchMail(user, url, ttlHours): Promise<'smtp'|'fallback'>
}
```
### 3.3 `MailService` với fallback dev
File: `src/services/notification/notificationEmailService.ts`
- Detect SMTP đã config: `Config.email.host !== 'smtp.example.com' && user && pass`.
- Nếu có → `nodemailer.createTransport` thật, `sendmail` trả `{ mode: 'smtp', info }`.
- Nếu không → ghi vào `dev-mail.log` (append), in console `[email fallback]`.
- Đây là cách dev test email verification mà không cần Gmail/SendGrid.
### 3.4 Email template
File: `src/templates/email/emailVerification.ts`
- Function `getEmailVerificationEmail({ email, fullName, verificationUrl, expiresInHours })` trả HTML tiếng Việt + tiếng Anh.
- Nút "Xác thực email / Verify email" link tới `${verificationUrl}`.
### 3.5 Config
- `EMAIL_VERIFICATION_TTL_HOURS=24` (env-schema, default 24).
- `Config.emailVerification.tokenTtlHours` exposed.
---
## 4. Trạng thái Phase 2 ✅ (TypeScript compile clean)
### 4.1 `src/constants/errors/auth.ts` — thêm 5 error codes
```ts
EMAIL_NOT_VERIFIED // 403, login blocked cho pending_verification
VERIFICATION_TOKEN_INVALID // 400, token không tồn tại / đã dùng
VERIFICATION_TOKEN_EXPIRED // 400
VERIFICATION_RESEND_COOLDOWN // 429 (reserved, hiện chưa throw)
USER_ALREADY_VERIFIED // 400, resend khi đã verify
```
Tất cả trong i18n vi + en.
### 4.2 `src/contracts/auth/schema.ts` — schema updates
- `RegisterBodySchema` thêm: `full_name?`, `address?`, `gender?` (enum male/female/other),
`date_of_birth?` (iso.date), `terms_accepted?` (bool). (Giữ nguyên email, password, username, first_name, last_name, phone.)
- `RegisterResponseDataSchema` thêm: `email_verified_at`, `verification_sent_at`,
`verification_expires_at`, `verification_email_mode` ('smtp'|'fallback'), `message`.
- Mới: `VerifyEmailQuerySchema` (token: string), `VerifyEmailResponseDataSchema` + `VerifyEmailResponseSchema`.
- Mới: `ResendVerificationBodySchema` (email), `ResendVerificationResponseDataSchema` + `ResendVerificationResponseSchema`.
- Mới: type `VerifyEmailResponseData`, `ResendVerificationResponseData` qua `z.infer`.
### 4.3 `src/contracts/auth/paths.ts` — OpenAPI
- Thêm 2 paths: `GET /api/v1/auth/verify-email`, `POST /api/v1/auth/resend-verification`.
- Cập nhật description cho `POST /api/v1/auth/register` (nhấn mạnh pending_verification + gửi email).
### 4.4 `src/services/auth/authService.ts` — register() thay đổi
- `RegisterData` interface thêm: `full_name?`, `address?`, `gender?`,
`date_of_birth?`, `terms_accepted?`, **`skipEmailVerification?: boolean`**.
- Derive `first_name`/`last_name` từ `full_name` nếu chưa có.
- Tạo user với `status = skipEmailVerification ? ACTIVE : PENDING_VERIFICATION`,
`email_verified_at = skipEmailVerification ? new Date() : null`.
- `zaloLogin` giờ gọi `register({ ..., skipEmailVerification: true })` vì OAuth đã xác minh user.
- `login()` thêm guard: nếu `user.status === PENDING_VERIFICATION` → throw `EMAIL_NOT_VERIFIED` (thay vì generic `ACCOUNT_INACTIVE`).
### 4.5 `src/controllers/api/v1/auth/register.ts` — mới (full)
- Rate limit `5 / 15 phút`. Zod validate `RegisterBodySchema`.
- Check `Config.auth.enableRegister` (nếu false → 403).
- Gọi `AuthService.register(req.body)`.
- Sau khi tạo user thành công, gọi `EmailVerificationService.getInstance().sendVerificationEmail(user)`.
- **Catch lỗi mail**: log warning, vẫn trả 200 (user đã được tạo, có thể resend).
- Response data: `id`, `email`, `username`, `first_name`, `last_name`, `status`, `email_verified_at`, `verification_sent_at`, `verification_expires_at`, `verification_email_mode`, `message`.
### 4.6 `src/controllers/api/v1/auth/verify-email.ts` — mới
- Rate limit `30 / 5 phút`. Validate query `VerifyEmailQuerySchema`.
- Gọi `EmailVerificationService.getInstance().verifyToken(token)`.
- Switch `outcome.kind`:
- `not_found``VERIFICATION_TOKEN_INVALID`
- `used``VERIFICATION_TOKEN_INVALID` (override message tiếng Việt)
- `expired``VERIFICATION_TOKEN_EXPIRED`
- `ok` → audit `EMAIL_VERIFIED`, trả `VerifyEmailResponseDataSchema`.
### 4.7 `src/controllers/api/v1/auth/resend-verification.ts` — mới
- Rate limit `3 / 5 phút`. Validate body.
- Generic 200 để chống email enumeration.
- Nếu user không tồn tại → trả `sent: true` giả.
- Nếu user đã verify → trả message "already verified".
- Nếu chưa verify → `EmailVerificationService.resendVerificationEmail(user)`.
---
## 5. Trạng thái Phase 3 ⏳ (ĐANG LÀM DỞ)
### 5.1 Đã xong
#### `src/oidc/views/register.hbs` — form đầy đủ fields
- 2-column grid (responsive), các field: email, first_name, last_name, phone, gender (select), address, date_of_birth, password, confirmPassword, terms_accepted checkbox.
- Submit `POST /oidc/interaction/:uid/register`.
- Hiển thị `{{ client }}` trong subtitle.
- Pre-fill email nếu user đã nhập `?email=...` trên URL.
#### `src/oidc/views/check-email.hbs` — mới
- Icon 📬, message "Check your email", hiển thị email + client name.
- Nếu `devVerificationUrl` (dev fallback) → box vàng hiển thị link click được.
- Form `POST /oidc/interaction/:uid/resend-verification` với hidden email.
- Hiển thị `resendMessage` / `resendError` sau submit.
#### `src/oidc/oidcInteractionsController.ts` — partial
- ✅ Imports thêm: `UserRoleProvider`, `RoleProvider`, `UserRoleEnum`, `EmailVerificationService`, `Logger`.
-`GET /:uid/register` thêm helper `getClientIdFromUid(uid)`, truyền `prefill` object.
-`validateCredentials` (login path) thêm message riêng cho `status === 'pending_verification'`.
### 5.2 CẦN LÀM TIẾP ❗ (CHƯA SỬA)
#### 5.2.1 Viết helper `getClientIdFromUid(uid: string): Promise<string>`
```ts
// Đặt trước `router.get('/:uid/register', ...)` (khoảng line 80-90)
async function getClientIdFromUid(uid: string): Promise<string> {
try {
const row = await OidcService.getInteractionByUid(uid);
const params = row?.payload?.params;
if (params && typeof params.client_id === 'string') return params.client_id;
} catch (err) {
Logger.warn(`[getClientIdFromUid] failed for ${uid}: ${(err as Error).message ?? err}`);
}
return '';
}
```
#### 5.2.2 Rewrite `POST /:uid/register` (DÒNG ~238-356 trong oidcInteractionsController.ts)
**Mục tiêu mới**:
1. Accept body: `{ email, password, confirmPassword, username?, first_name?, last_name?, full_name?, phone?, address?, gender?, date_of_birth?, terms_accepted? }`.
2. Validate: required (email, password, confirmPassword, terms_accepted === '1' or true).
3. Validate `password === confirmPassword`; password min 12 chars.
4. Derive `first_name`/`last_name` từ `full_name` nếu chưa có (giống authService).
5. Tạo user qua `UserProvider` với `status: 'pending_verification'`, `email_verified_at: null`.
6. Tạo `UserAuth` record với password hash.
7. Gán role mặc định `UserRoleEnum.USER` qua `UserRoleProvider` + `RoleProvider`.
8. Gọi `EmailVerificationService.getInstance().sendVerificationEmail(user)`.
9. **KHÔNG auto-login**, **KHÔNG gọi `OidcService.interactionFinished`**.
10. Render `check-email` view với: `uid`, `csrfToken`, `client` (lấy từ `getClientIdFromUid(uid)`), `email` (lại), `expiresInHours` (lấy từ `Config.emailVerification.tokenTtlHours ?? 24`), `devVerificationUrl` (CHỈ khi `nodeEnv === 'development' && verificationToken`).
**Gợi ý lấy `devVerificationUrl`**: vì `sendVerificationEmail` trả `{ token, expiresAt }`, bạn có thể:
- Lưu plaintext token tạm thời vào `res.locals` hoặc return từ service.
- Dùng `EmailVerificationService.getInstance().buildVerificationUrl(token)` để build URL.
**Pattern hiện tại đang có** (phải thay thế):
```ts
// CŨ - tự động login + finish interaction:
const user = await validateCredentials(trimmedEmail, password);
const result: LoginResult = { login: { accountId: user.id, ... } };
return await OidcService.interactionFinished(req, res, result, { mergeWithLastSubmission: false });
```
**Pattern mới cần viết**:
```ts
// MỚI - chỉ render check-email, KHÔNG finish interaction:
return res.status(200).render('check-email', {
...viewContext(req, uid, clientId),
email: trimmedEmail,
expiresInHours: Config.emailVerification?.tokenTtlHours ?? 24,
devVerificationUrl: process.env.NODE_ENV === 'development' ? devUrl : null,
resendMessage: null,
resendError: null,
});
```
#### 5.2.3 Thêm `POST /:uid/resend-verification` (mới, ngay sau POST /:uid/register)
```ts
router.post('/:uid/resend-verification', async (req, res) => {
const uid = req.params.uid;
const email = (req.body?.email ?? '').toString().trim().toLowerCase();
const clientId = await getClientIdFromUid(uid);
let resendMessage: string | null = null;
let resendError: string | null = null;
let devVerificationUrl: string | null = null;
try {
const user = await User.findOne({ where: { email } });
if (user && !user.email_verified_at) {
const result = await EmailVerificationService.getInstance().resendVerificationEmail(user);
if (process.env.NODE_ENV === 'development') {
// Re-build URL from a fresh token to show the dev link again
const { token } = await EmailVerificationService.getInstance().createToken(user.id);
devVerificationUrl = EmailVerificationService.getInstance().buildVerificationUrl(token);
}
resendMessage = 'Email xác thực đã được gửi lại. / Verification email re-sent.';
} else if (user?.email_verified_at) {
resendMessage = 'Email đã được xác thực. Bạn có thể đăng nhập ngay. / Already verified.';
} else {
resendMessage = 'Nếu email tồn tại, bạn sẽ nhận được liên kết. / If the email exists, a link will be sent.';
}
} catch (err) {
Logger.warn(`[resend-verification OIDC] ${(err as Error).message ?? err}`);
resendError = 'Không thể gửi lại email. Vui lòng thử lại sau. / Could not resend. Please try again later.';
}
return res.status(200).render('check-email', {
...viewContext(req, uid, clientId),
email,
expiresInHours: Config.emailVerification?.tokenTtlHours ?? 24,
devVerificationUrl,
resendMessage,
resendError,
});
});
```
⚠️ Lưu ý: endpoint này sẽ **vẫn giữ interaction uid sống** (không xóa grant) để user có thể login sau khi verify xong. Khi user click link xác thực → trở lại trang login (`/oidc/interaction/:uid`) → đăng nhập bình thường.
### 5.3 Sau khi xong Phase 3
- Chạy `npx tsc --noEmit` — phải 0 errors.
- Manual test qua Swagger + OIDC (Phase 4).
---
## 6. Phase 4 — Test end-to-end (CHƯA LÀM)
Cần test:
1. **REST register**: `POST /api/v1/auth/register` với đầy đủ fields → user tạo với `status=pending_verification` → 200 với `verification_email_mode: 'fallback'` (vì dev không có SMTP) → check `dev-mail.log` có link.
2. **REST verify**: Mở link trong `dev-mail.log``GET /api/v1/auth/verify-email?token=...` → 200 với `verified: true` → check DB: `user.status='active'`, `user.email_verified_at` not null, `email_verify_tokens.used_at` not null.
3. **REST login trước khi verify**: thử login với tài khoản pending → 403 `EMAIL_NOT_VERIFIED`.
4. **REST login sau khi verify**: thành công, nhận tokens.
5. **REST resend**: gọi `/auth/resend-verification` → 200, check `dev-mail.log` có link mới.
6. **OIDC flow**: chạy `project-a-demo` (xem `RUN.md`), truy cập app → redirect tới SSO → click "Create an account" → form mới có đầy đủ fields → submit → thấy trang `check-email.hbs` (KHÔNG redirect tới `project-a-demo`).
7. **OIDC verify**: mở link trong `dev-mail.log``verify-email` → quay lại `/oidc/interaction/:uid` → login thành công → redirect tới `project-a-demo?code=...`.
---
## 7. Phase 5 — Update `RUN.md` (CHƯA LÀM)
Cần thêm section mới:
- "Email verification flow" giải thích luồng REST + OIDC.
- Mô tả dev mode (fallback to `dev-mail.log`).
- Liệt kê các env vars: `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USER`, `EMAIL_PASS`, `EMAIL_FROM`, `EMAIL_VERIFICATION_TTL_HOURS`.
- Note: trong production bắt buộc config SMTP, nếu không user không nhận được email.
---
## 8. Files đã sửa (chốt để commit)
```
feat(auth): phase 1+2 email verification
- src/constants/errors/auth.ts (5 new errors)
- src/contracts/auth/schema.ts (RegisterBody + 2 new schemas)
- src/contracts/auth/paths.ts (2 new OpenAPI paths)
- src/services/auth/authService.ts (register: pending_verification, skipEmailVerification)
- src/services/auth/emailVerificationService.ts (resendVerificationEmail)
- src/controllers/api/v1/auth/register.ts (send verification email)
- src/controllers/api/v1/auth/verify-email.ts (NEW)
- src/controllers/api/v1/auth/resend-verification.ts (NEW)
feat(auth): phase 3 OIDC check-email (WIP)
- src/oidc/views/register.hbs (full fields)
- src/oidc/views/check-email.hbs (NEW)
- src/oidc/oidcInteractionsController.ts (partial: imports + GET register + login block; POST register + resend CHƯA XONG)
```
---
## 9. Commit messages đã dùng / dự kiến
```bash
# Chưa commit. Sau khi xong Phase 3:
git add -A
git commit -m "feat(auth): email verification flow (REST + OIDC WIP)
- New users get status=pending_verification and must click
the email link before they can log in
- REST: /api/v1/auth/register, /verify-email, /resend-verification
- OIDC: check-email.hbs view, full register form, no auto-login
- Dev mode falls back to dev-mail.log when SMTP not configured
- Refs: pending_verification, email_verified_at columns,
email_verify_tokens table (migration 040)"
```
---
## 10. Lệnh thường dùng (để khỏi quên)
```bash
cd c:\VietProDev\sso\sso-vietprodev-backend
# Type-check only
npx tsc --noEmit
# Run dev server
pnpm run dev
# Build swagger
pnpm swagger:generate
# Tail dev mail log (Windows PowerShell)
Get-Content -Path ".\dev-mail.log" -Wait -Tail 20
# Clear test users (PostgreSQL)
# psql: DELETE FROM users WHERE email LIKE 'test%@example.com';
```
---
## 11. ⚠️ Những chỗ KHÔNG ĐƯỢC ĐỤNG
- `src/services/notification/notificationEmailService.ts` (MailService) — đã work, fallback OK.
- `src/services/auth/emailVerificationService.ts` — core logic verify token, đã work.
- `src/templates/email/emailVerification.ts` — template, không sửa trừ khi đổi design.
- `src/models/EmailVerifyToken.ts` — schema Sequelize.
- `src/models/User.ts` — đã có `address`, `email_verified_at`, `status='pending_verification'`.
- Migration `040_*` — đã apply, không rollback.
- `src/services/auth/authService.ts` method `register()` (Phase 2) — đã pass `npx tsc --noEmit`.
- `src/contracts/auth/schema.ts` & `paths.ts` — đã pass.
- `src/controllers/api/v1/auth/{register,verify-email,resend-verification}.ts` — đã pass.
**CHỈ SỬA**: `src/oidc/oidcInteractionsController.ts` (Phase 3 còn dở) + `src/oidc/views/{register,check-email}.hbs` (đã sửa, không đụng lại).
---
## 12. Quick start cho chat mới
Mở chat mới, paste đoạn sau:
```
"Tiếp tục Phase 3 (OIDC register flow) cho sso-vietprodev-backend.
Đọc @sso-vietprodev-backend/PROGRESS.md trước để nắm context.
Phase 3 còn dở: chưa rewrite POST /oidc/interaction/:uid/register,
chưa thêm POST /:uid/resend-verification, chưa có helper
getClientIdFromUid. Sau khi xong, chạy `npx tsc --noEmit` rồi
sang Phase 4 (test end-to-end)."
```
---
## 13. Phase 3 — DONE ✅ (2026-06-18)
### Đã hoàn thành
- [x] Rewrite `POST /oidc/interaction/:uid/register` — tạo user với `status=pending_verification`, không auto-login, render `verify-pending.hbs`
- [x] Thêm `POST /oidc/interaction/:uid/resend-verification` — issue token mới, response identical cho cả email tồn tại / không (chống enumeration)
- [x] `EmailVerificationService` (`src/services/auth/emailVerificationService.ts`, working tree) — mint SHA-256 hashed token, persist `email_verify_tokens`, gửi qua `MailService` (dev fallback `dev-mail.log`)
- [x] Default `user` role assign cùng transaction với User + UserAuth (qua `Role` + `UserRole` models)
- [x] View `src/oidc/views/verify-pending.hbs` — UI "Check your email" + nút Resend + dev mode hiện URL verify
- [x] View `src/oidc/views/register.hbs` — 2-column grid responsive, full fields (email, first_name, last_name, phone, gender, address, date_of_birth, password, confirmPassword, terms_accepted), pre-fill email từ `?email=`
- [x] Helper `getClientIdFromUid` (lookup `provider.Interaction.find(uid).params.client_id`)
- [x] Helpers `csrfToken` / `authCode` phục hồi (đã mất khi rebase develop)
- [x] `npx tsc --noEmit` cho `src/oidc/oidcInteractionsController.ts`**0 errors** (pre-existing errors ở `controllers/api/v1/auth/{verify-email,resend-verification}.ts` + `server.ts` out of scope §11).
### Git flow
- Branch: `feat/oidc-register-verify-email` (từ `develop`)
- Commit Phase 3 đầu: `cf35eb1 feat(oidc): email-verified register flow + resend endpoint`
- Sau khi restore working tree (gồm Phase 1+2 files) vào develop, controller đã được rewrite lại trong session này, dùng cùng `EmailVerificationService` working tree thay vì `src/oidc/emailVerificationService.ts` riêng (đã bỏ để tránh 2 implementation trùng nhau).
### Phase 4 cần làm (CHƯA)
- [ ] REST `POST /api/v1/auth/verify-email?token=...` — flip `user.status = 'active'` + `email_verified_at = now()` (Phase 2 working tree đã có controller, bị lỗi TypeScript import contracts/auth/schema — cần fix khi làm Phase 4-5 tiếp)
- [ ] Test end-to-end: register → check email (dev URL) → click → status='active' → login OK
- [ ] Rate-limit resend (dùng Redis) — currently chỉ dedupe qua BullMQ
- [ ] Cleanup expired tokens (cron daily)
---
## 14. Tooling đã cài cho monorepo (2026-06-18)
### Matt Pocock skills (`.cursor/skills/`)
- Source: https://github.com/mattpocock/skills (MIT)
- Cài: `git clone https://github.com/mattpocock/skills.git .cursor/skills` (ở root monorepo)
- 34 skills, quan trọng cho SSO: `tdd`, `diagnosing-bugs`, `codebase-design`, `domain-modeling`, `grill-with-docs`, `improve-codebase-architecture`, `resolving-merge-conflicts`
- Productivity: `handoff`, `grill-me`
- **Không gitignore** — folder support tool local, không push lên (chỉ `@sso-vietprodev-backend` publish)
### Hermes Agent CLI (`hermes`)
- Source: https://github.com/NousResearch/hermes-agent (MIT)
- Cài: `iex (irm https://hermes-agent.nousresearch.com/install.ps1)` (chạy native, không cần WSL)
- Install dir: `%LOCALAPPDATA%\hermes\hermes-agent`
- Python venv: `%LOCALAPPDATA%\hermes\hermes-agent\.venv`
- Version: `v0.16.0 (2026.6.5)` — Python 3.11.9, OpenAI SDK 2.24.0
- PATH: `%LOCALAPPDATA%\hermes\hermes-agent\.venv\Scripts` đã add vào User PATH
- Verify: `hermes --version`
- Dùng: `hermes` (CLI) hoặc `hermes --tui` (TUI); `hermes model` để chọn LLM provider
- **Lần đầu chạy cần** `hermes setup` (chọn provider: Nous Portal / OpenAI / Anthropic / OpenRouter / v.v.)
---
## 15. Phase 3 (re-applied) + Phase 4 + Phase 5 — DONE ✅ (2026-06-18, session 2)
### Tình huống
Sau session 1, working tree của `develop` đã được restore từ stash `@{0}` (218 files cũ) nhưng **thiếu** phần Phase 3 wiring trong `oidcInteractionsController.ts` (file bị downgrade về stub 501). Stash list cũng có chứa 218 file codebase sso-vietprodev-old không liên quan — đã identify và KHÔNG dùng.
Branch `feat/oidc-register-verify-email` (commit `cf35eb1`) chứa Phase 3 done nhưng **thiếu** các file Phase 1+2 mới (services, controllers, templates, migrations). Hai implementation của `EmailVerificationService` cũng khác nhau (cf35eb1 dùng `EmailVerifyTokenProvider` + `NotificationService`; working tree dùng `EmailVerifyToken` model + `MailService`).
### Hướng giải quyết (an toàn)
- Tag backup: `backup/develop-pre-phase3-restore` (lưu cả commit + working tree cũ).
- Backup feature branch files ra `/tmp/sso-stash-backup/`.
- **Giữ Phase 1+2 của working tree** (đã pass `npx tsc --noEmit` per §11).
- **Bỏ `src/oidc/emailVerificationService.ts` riêng** (của cf35eb1) để chỉ dùng `EmailVerificationService` của `src/services/auth/emailVerificationService.ts`.
- **Rewrite `oidcInteractionsController.ts`** (working tree) theo pattern từ cf35eb1, nhưng wire với working tree's `EmailVerificationService`.
### Kết quả
-`src/oidc/oidcInteractionsController.ts` — rewrite với:
- `POST /:uid/register` — full fields, status `pending_verification`, không auto-login, render `verify-pending.hbs`. Transaction tạo User + UserAuth + UserRole mặc định `user` (nếu đã seed).
- `POST /:uid/resend-verification` — generic response (chống email enumeration), render `verify-pending.hbs`.
- `GET /:uid/register` — render form với `prefill` từ query `?email=`.
- Helper `getClientIdFromUid` (lookup `provider.Interaction.find(uid).params.client_id`).
- Helper `csrfToken`, `getVerificationTtlHours`, `parseIsoDate`, `isDevMode`.
- `validateCredentials` vẫn placeholder (TODO: wire với UserProvider + PasswordService).
- Logger thay cho console.log; `Logger.error` / `Logger.warn` cho audit failure.
-`src/oidc/views/register.hbs` — 2-column grid responsive, full fields (email, first_name, last_name, phone, gender, address, date_of_birth, password, confirmPassword, terms_accepted), pre-fill email từ `?email=`.
-`src/oidc/views/verify-pending.hbs` — đã có từ session 1 (giữ nguyên).
-`tests/unit/services/emailVerification.service.test.ts` — 8 unit tests, **8/8 PASS**:
- `buildVerificationUrl` (basic + strip trailing slashes)
- `createToken` (SHA-256 hash, persists row)
- `verifyToken` (4 outcomes: not_found / used / expired / ok)
- `resendVerificationEmail` (throws USER_ALREADY_VERIFIED)
-`RUN.md` §6.5 — section mới: "Email Verification Flow" gồm:
- REST + OIDC step-by-step
- Dev mode fallback
- Curl examples (register, verify, resend, login blocked)
- Env vars table
- Manual test checklist 8 items
-`RUN.md` §3.4 — endpoint table thêm 3 routes mới (`/auth/register`, `/auth/verify-email`, `/auth/resend-verification`).
-`RUN.md` §2.4 — note migration 040 (email_verified_at + address + email_verify_tokens).
### Pre-existing errors (KHÔNG trong scope)
- `src/controllers/api/v1/auth/resend-verification.ts` (3 errors) — import `ResendVerificationBodySchema`, `ResendVerificationResponseData` chưa có trong `contracts/auth/schema`.
- `src/controllers/api/v1/auth/verify-email.ts` (3 errors) — import `VerifyEmailQuerySchema`, `VerifyEmailResponseDataSchema`, `VerifyEmailResponseData` chưa có.
- `src/server.ts` (2 errors) — `MultiPoolService.autoLoadPools` không tồn tại (PROGRESS §11 note: "đã pass tsc — KHÔNG ĐƯỤNG" nhưng thực tế file có 2 lỗi pre-existing).
Tất cả 3 file trên đều không nằm trong scope Phase 3 rewrite. Sẽ xử lý riêng trong task "Phase 4b — fix pre-existing TS errors" nếu user yêu cầu.
### Git flow tiếp theo
- Working tree `develop` có ~36 file modified/untracked sau session này.
- Tạo commit `feat(oidc): re-apply Phase 3 wiring on top of develop restore` trên branch `develop`.
- Sau đó fast-forward `feat/oidc-register-verify-email` lên `develop` (hoặc merge ngược để có 1 history thẳng).
- Push lên remote + tạo MR.
---
## 16. Backup / restore cheatsheet
### Tags hiện có
- `backup/develop-pre-phase3-restore` — lưu trạng thái develop TRƯỚC khi restore stash (36 dirty files, 1 stash, working tree Phase 1+2 rỗng).
### Files backup ngoài
- `/tmp/sso-stash-backup/files.txt` — danh sách 218 file trong `stash@{0}` (codebase cũ sso-vietprodev-old).
- `/tmp/sso-stash-backup/oidcInteractionsController.ts.cf35eb1.txt` — Phase 3 controller từ `cf35eb1`.
- `/tmp/sso-stash-backup/emailVerificationService.ts.cf35eb1.txt` — Phase 3 service từ `cf35eb1` (KHÔNG dùng, chỉ tham khảo).
- `/tmp/sso-stash-backup/verify-pending.hbs.cf35eb1.txt` — Phase 3 view (giống working tree).
- `/tmp/sso-stash-backup/controller-diff.patch` — diff giữa working tree controller (stub) và `cf35eb1` controller (full).
- `/tmp/sso-stash-backup/tsc-after-controller-*.log` — tsc logs từng step trong session này.
### Lệnh rollback an toàn
```bash
# Rollback toàn bộ working tree về backup tag
git checkout backup/develop-pre-phase3-restore -- .
# Hoặc restore một file
git checkout backup/develop-pre-phase3-restore -- src/oidc/oidcInteractionsController.ts
```
# Developer Guide — SSO VietProDev Backend
> **Mục tiêu:** SSO server chạy ở `http://localhost:3001`, đăng ký 2 demo app (project-a-demo, project-b-demo), và test luồng OIDC Authorization Code từ đầu đến cuối.
---
## Table of Contents
1. [Prerequisites](#1-prerequisites)
2. [Quick Start (5 phút)](#2-quick-start)
3. [Environment Variables](#3-environment-variables)
4. [OIDC Clients Registration](#4-oidc-clients-registration)
5. [Demo Apps Setup](#5-demo-apps-setup)
6. [Test End-to-End Flow](#6-test-end-to-end-flow)
7. [Project Structure](#7-project-structure)
8. [Troubleshooting](#8-troubleshooting)
---
## 1. Prerequisites
| Tool | Version | Ghi chú |
|------|---------|---------|
| **Node.js** | >= 20.x | LTS recommended |
| **pnpm** | >= 9.x | Package manager |
| **Docker Desktop** | Latest | Postgres, Redis, MongoDB, MinIO |
| **Git** | Latest | Version control |
| **psql** (tuỳ chọn) | 15+ | Inspect DB thủ công |
> **Windows:** Dùng PowerShell. Tất cả lệnh tương thích PowerShell + bash.
---
## 2. Quick Start
### 2.1. Clone & cài dependencies
```bash
git clone <repo-url> sso-vietprodev
cd sso-vietprodev/sso-vietprodev-backend
pnpm install
```
### 2.2. Tạo file `.env`
```bash
# PowerShell
Copy-Item .env.example .env
# bash
cp .env.example .env
```
> **Dev mode** tự sinh secret mặc định an toàn nếu `JWT_SECRET`, `JWT_REFRESH_SECRET`, `TOKEN_ENCRYPTION_KEY` quá ngắn (< 32 chars).
### 2.3. Khởi động infrastructure
```bash
docker compose up -d postgres redis mongo minio
```
Verify:
```bash
docker compose ps
```
```
NAME STATUS
sso-postgres running (healthy)
sso-redis running (healthy)
sso-mongo running (healthy)
sso-minio running (healthy)
```
### 2.4. Chạy migration
```bash
pnpm migrate
```
Script tự động áp dụng các file `.sql` trong `sql/migrations/` theo thứ tự. Migration **038** đã được thực thi — xoá các bảng facility template cũ (residents, buildings, apartments, rooms, beds...). Migration **040** thêm `email_verified_at` + `address` vào `users` và tạo bảng `email_verify_tokens` cho luồng email verification.
### 2.5. Tạo admin user
```bash
docker exec -i sso-postgres psql -U postgres -d sso <<'SQL'
WITH new_user AS (
INSERT INTO users (id, email, username, first_name, last_name, status, created_at, updated_at)
VALUES (
gen_random_uuid(),
'admin@vietprodev.com',
'admin',
'System',
'Admin',
'active',
NOW(),
NOW()
)
RETURNING id
)
INSERT INTO user_auth (id, user_id, password_hash, is_oauth_only, password_changed_at, created_at, updated_at)
SELECT
gen_random_uuid(),
id,
crypt('Vietpro@123', gen_salt('bf', 12)),
false,
NOW(),
NOW(),
NOW()
FROM new_user;
SQL
```
> **Tài khoản:** `admin@vietprodev.com` / `Vietpro@123`
### 2.6. Khởi động SSO server
```bash
pnpm run dev
```
Output mong đợi:
```
[OK] OIDC provider initialized
[OK] OpenAPI ready
Swagger: http://localhost:3001/swagger/index
[OK] Redis connected
[OK] Jobs initialized
[OK] Partitions ready
[OK] Listening on port 3001
```
### 2.7. Verify
```bash
# Health check
curl http://localhost:3001/health
# OIDC Discovery
curl http://localhost:3001/.well-known/openid-configuration
```
---
## 3. Environment Variables
File `.env.example` chứa 17 sections:
| # | Section | Biến quan trọng |
|---|---------|-----------------|
| 1 | Server | `PORT`, `BACKEND_URL`, `FRONTEND_URL` |
| 2 | Database — Primary | `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` |
| 2b | Database — Backup/HA | `SSO_LOGIN_BACKUP_URL` |
| 2c | External Project DBs | `PROJECT_A_DATABASE_URL`, `PROJECT_B_DATABASE_URL` |
| 3 | MongoDB | `MONGODB_AUDIT_URL`, `MONGODB_AUDIT_DATABASE` |
| 4 | Redis | `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD` |
| 5 | Auth & Security | `JWT_SECRET`, `JWT_REFRESH_SECRET`, `TOKEN_ENCRYPTION_KEY`, `BCRYPT_ROUNDS` |
| 6 | Admin API | `ADMIN_API_KEY` |
| 7 | OIDC Provider | `OIDC_ISSUER`, `OIDC_ACCESS_TOKEN_TTL`, `OIDC_COOKIE_KEYS`, `OIDC_PRIVATE_JWK_PATH` |
| 8 | Cookies | `COOKIE_DOMAIN`, `COOKIE_CROSS_SITE` |
| 9 | CORS | `CORS_ORIGINS` |
| 10 | Sessions | `SESSION_CACHE_TTL` |
| 11 | Storage | `STORAGE_PROVIDER`, `MINIO_*` |
| 12 | Email | `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_USER`, `EMAIL_PASS` |
| 13–17 | Social / Notifications / Jobs / Logging | Tuỳ môi trường |
### So sánh `.env` cũ vs mới
| Thành phần | `.env` cũ | `.env` mới |
|------------|-----------|-----------|
| Database | `SSO_LOGIN_PRIMARY_URL` (connection string) | `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_NAME` (individual) |
| Backup DB | `SSO_LOGIN_BACKUP_URL` | `SSO_LOGIN_BACKUP_URL` ✅ giữ nguyên |
| External DBs | `PROJECT_A_DATABASE_URL` / `PROJECT_B_DATABASE_URL` (commented) | Uncomment sau khi tạo project DBs |
| OIDC Config | `OIDC_ISSUER`, `OIDC_ACCESS_TOKEN_TTL`, `OIDC_REFRESH_TOKEN_TTL` | Giữ nguyên + thêm `OIDC_COOKIE_KEYS` |
| JWKS | Không có | `OIDC_PRIVATE_JWK_PATH`, `OIDC_PUBLIC_JWKS_PATH` |
| Cookie | `COOKIE_DOMAIN=.localhost` | Giữ nguyên |
| HA Config | Không có | Thêm `DB_HEALTH_CRON`, `CIRCUIT_BREAKER_*` |
| Notifications | Không có section riêng | Tách thành Queue, Zalo, OneSignal, VAPID riêng |
| Cron Jobs | Không có | `BACKUP_VERIFY_CRON`, `DB_HEALTH_CRON`, `AUDIT_CLEANUP_CRON` |
| Virus Scan | Không có | `VIRUS_SCAN_ENABLED`, `CLAM_HOST`, `CLAM_PORT` |
| Idempotency | Không có | `IDEMPOTENCY_KEY_TTL`, `CIRCUIT_BREAKER_*` |
| Cấu trúc | Flat, không phân section | 17 sections rõ ràng, comment đầy đủ |
### Development vs Production
| Setting | Development | Production |
|---------|-------------|------------|
| `NODE_ENV` | `development` | `staging` / `production` |
| Secrets | Auto-generated if too short | Must set explicitly |
| `ADMIN_API_KEY` | `change-me-admin-api-key` (dev) | Strong random string |
| `DB_HOST` | `localhost` | Internal Docker network name |
| `COOKIE_DOMAIN` | `.localhost` | `.vietprodev.com` |
| `FORCE_SECURE_COOKIES` | `false` | `true` |
---
## 4. OIDC Clients Registration
Cần đăng ký **2 OIDC clients** — một cho mỗi demo app. Sau khi SSO server chạy, gọi API:
### 4.1. Register Project A Demo Client
```bash
curl -X POST http://localhost:3001/admin/clients \
-H "X-Admin-Api-Key: change-me-admin-api-key" \
-H "Content-Type: application/json" \
-d '{
"app_code": "project-a",
"client_id": "project-a-demo",
"client_secret": "project-a-demo-secret-123456",
"name": "Project A Demo",
"redirect_uris": ["http://localhost:4001/auth/callback"],
"post_logout_redirect_uris": ["http://localhost:4001"],
"scopes": ["openid", "profile", "email"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post",
"require_pkce": false
}'
```
### 4.2. Register Project B Demo Client
```bash
curl -X POST http://localhost:3001/admin/clients \
-H "X-Admin-Api-Key: change-me-admin-api-key" \
-H "Content-Type: application/json" \
-d '{
"app_code": "project-b",
"client_id": "project-b-demo",
"client_secret": "project-b-demo-secret-654321",
"name": "Project B Demo",
"redirect_uris": ["http://localhost:4002/auth/callback"],
"post_logout_redirect_uris": ["http://localhost:4002"],
"scopes": ["openid", "profile", "email"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_post",
"require_pkce": false
}'
```
### 4.3. Verify clients registered
```bash
curl http://localhost:3001/admin/clients \
-H "X-Admin-Api-Key: change-me-admin-api-key"
```
---
## 5. Demo Apps Setup
### 5.1. Project A Demo
```bash
cd c:/VietProDev/sso/project-a-demo
npm install
npm run dev
```
- **URL:** http://localhost:4001
- **Client ID:** `project-a-demo`
- **Client Secret:** `project-a-demo-secret-123456`
- **Redirect URI:** `http://localhost:4001/auth/callback`
### 5.2. Project B Demo
```bash
cd c:/VietProDev/sso/project-b-demo
npm install
npm run dev
```
- **URL:** http://localhost:4002
- **Client ID:** `project-b-demo`
- **Client Secret:** `project-b-demo-secret-654321`
- **Redirect URI:** `http://localhost:4002/auth/callback`
---
## 6. Test End-to-End Flow
### 6.1. OIDC Authorization Code Flow
```
┌─────────────────────────────────────────────────────────────────────┐
│ 1. User clicks "Login with SSO" │
│ GET /auth/login → redirect to SSO │
│ │
│ 2. SSO shows login page (OIDC interaction) │
│ GET http://localhost:3001/oidc/interaction/:uid │
│ │
│ 3. User enters credentials │
│ POST http://localhost:3001/oidc/interaction/:uid/login │
│ │
│ 4. SSO shows consent page (first time only) │
│ GET http://localhost:3001/oidc/interaction/:uid │
│ │
│ 5. User approves scopes │
│ POST http://localhost:3001/oidc/interaction/:uid/confirm │
│ │
│ 6. SSO redirects back with authorization code │
│ GET http://localhost:4001/auth/callback?code=XXX&state=YYY │
│ │
│ 7. Demo app exchanges code for tokens │
│ POST http://localhost:3001/oauth/token │
│ │
│ 8. Demo app fetches user info │
│ GET http://localhost:3001/oauth/userinfo │
│ │
│ 9. Demo app shows user dashboard │
│ GET / │
└─────────────────────────────────────────────────────────────────────┘
```
### 6.2. Browser Flow (Recommended)
1. Mở http://localhost:4001 (Project A) hoặc http://localhost:4002 (Project B)
2. Click **Login with SSO**
3. Nhập `admin@vietprodev.com` / `Vietpro@123`
4. Click **Authorize / Consent** (lần đầu)
5. Demo app hiển thị thông tin user
### 6.3. curl Flow (for verification)
**Bước 1 — Authorize:**
```
http://localhost:3001/oauth/authorize
?client_id=project-a-demo
&response_type=code
&scope=openid%20profile%20email
&redirect_uri=http%3A%2F%2Flocalhost%3A4001%2Fauth%2Fcallback
&state=xyz123
```
**Bước 2 — Đổi code lấy token:**
```bash
curl -X POST http://localhost:3001/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=<paste-code-here>" \
-d "redirect_uri=http://localhost:4001/auth/callback" \
-d "client_id=project-a-demo" \
-d "client_secret=project-a-demo-secret-123456"
```
**Bước 3 — Lấy userinfo:**
```bash
curl http://localhost:3001/oauth/userinfo \
-H "Authorization: Bearer <access_token>"
```
### 6.4. SSO endpoints reference
| Endpoint | Method | Auth | Mô tả |
|----------|--------|------|--------|
| `/.well-known/openid-configuration` | GET | None | OIDC discovery metadata |
| `/oauth/jwks` | GET | None | Public JWKS |
| `/oauth/authorize` | GET/POST | None | Authorization endpoint |
| `/oauth/token` | POST | client_secret | Token endpoint |
| `/oauth/userinfo` | GET | Bearer | User claims |
| `/oauth/introspect` | POST | client_secret | Token introspection |
| `/oauth/revoke` | POST | client_secret | Token revocation |
| `/oauth/logout` | GET/POST | None | Logout endpoint |
| `/oidc/interaction/:uid` | GET | None | Login/consent page |
| `/oidc/interaction/:uid/login` | POST | None | Login submission |
| `/oidc/interaction/:uid/register` | POST | None | Registration |
| `/oidc/interaction/:uid/confirm` | POST | None | Consent approval |
| `/oidc/interaction/:uid/resend-verification` | POST | None | Re-send verification email (OIDC flow) |
| `/api/v1/auth/register` | POST | None | REST: create account + send verification email |
| `/api/v1/auth/verify-email` | GET | None | REST: confirm verification token from email link |
| `/api/v1/auth/resend-verification` | POST | None | REST: re-send verification email |
---
## 6.5. Email Verification Flow
Mọi tài khoản mới (cả REST và OIDC) đều phải **xác thực email** trước khi đăng nhập. User mới có `status = 'pending_verification'`**không thể login** cho tới khi click link trong email.
### 6.5.1. Các bước (REST flow)
1. `POST /api/v1/auth/register` với body:
```json
{
"email": "user@example.com",
"password": "At-least-12-chars",
"username": "optional",
"first_name": "...",
"last_name": "...",
"phone": "+84...",
"address": "...",
"gender": "male|female|other",
"date_of_birth": "1990-01-15",
"terms_accepted": true
}
```
2. User được tạo với `status = 'pending_verification'`, `email_verified_at = null`.
3. Một token SHA-256 hash được lưu vào bảng `email_verify_tokens`.
4. Email chứa link `GET /api/v1/auth/verify-email?token=<plain>` được gửi tới user.
5. User click link token được đánh dấu `used_at`, `user.status` flip sang `active`, `user.email_verified_at = NOW()`.
6. Login thành công qua `/api/v1/auth/login`.
### 6.5.2. Các bước (OIDC flow)
1. Demo app (project-a-demo) redirect user tới SSO `/oauth/authorize?...`
2. SSO render `/oidc/interaction/:uid` (login page) nút **Create an account** dẫn tới `/oidc/interaction/:uid/register`.
3. User điền form đầy đủ (email, name, phone, address, gender, DOB, password, terms) `POST /oidc/interaction/:uid/register`.
4. Backend tạo user (status `pending_verification`), gán role `user` mặc định, gửi email verification, **không auto-login**.
5. SSO render `verify-pending.hbs` nút **Resend verification email** + (dev mode) link click trực tiếp.
6. User mở email, click link `GET /api/v1/auth/verify-email?token=...` flip `active`.
7. User quay lại `/oidc/interaction/<new_uid>` (qua lại authorize) login bình thường consent redirect về demo app.
### 6.5.3. Dev mode fallback
Khi `EMAIL_HOST` chưa được cấu hình (mặc định `smtp.example.com`), `NotificationEmailService` ghi link xác thực vào `dev-mail.log` thay gửi thật. Cách test nhanh:
```powershell
# Tail log khi đăng ký
Get-Content -Path ".\dev-mail.log" -Wait -Tail 20
```
Trên trang `verify-pending.hbs` ở dev mode, **một box vàng** hiển thị link xác thực trực tiếp — không cần mở file log.
### 6.5.4. Curl examples
**Register (REST):**
```bash
curl -X POST http://localhost:3001/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "MyStrongP@ssw0rd-12",
"first_name": "Test",
"last_name": "User",
"phone": "+84909090909",
"address": "Hanoi, Vietnam",
"gender": "male",
"date_of_birth": "1990-01-15",
"terms_accepted": true
}'
```
**Verify (REST) — lấy token từ `dev-mail.log`:**
```bash
curl "http://localhost:3001/api/v1/auth/verify-email?token=<paste-token-here>"
```
**Resend (REST):**
```bash
curl -X POST http://localhost:3001/api/v1/auth/resend-verification \
-H "Content-Type: application/json" \
-d '{ "email": "test@example.com" }'
```
**Login trước khi verify** → response `403 EMAIL_NOT_VERIFIED`:
```bash
curl -X POST http://localhost:3001/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{ "email": "test@example.com", "password": "MyStrongP@ssw0rd-12" }'
```
**Login sau khi verify** → 200 + tokens.
### 6.5.5. Env vars liên quan
| Var | Default | Mô tả |
|-----|---------|--------|
| `EMAIL_HOST` | `smtp.example.com` | SMTP server. Nếu để default, hệ thống rơi vào dev mode (ghi `dev-mail.log`). |
| `EMAIL_PORT` | `587` | SMTP port. |
| `EMAIL_USER` | `""` | SMTP username. |
| `EMAIL_PASS` | `""` | SMTP password. |
| `EMAIL_FROM` | `noreply@yourdomain.com` | From address. |
| `EMAIL_VERIFICATION_TTL_HOURS` | `24` | TTL của verification token (giờ). |
| `BACKEND_URL` | `http://localhost:3001` | Base URL dùng để build link verify trong email. |
> **Production:** **BẮT BUỘC** cấu hình SMTP (`EMAIL_HOST/PORT/USER/PASS`). Nếu không, user sẽ không nhận được email và sẽ bị kẹt ở `pending_verification`.
### 6.5.6. Test checklist thủ công
Sau khi `pnpm run dev` + chạy migration (đã bao gồm migration `040_add-email-verified-at-and-address`):
- [ ] **REST register** — POST `/api/v1/auth/register` đầy đủ fields → 200 với `verification_email_mode: 'fallback'`. Check `dev-mail.log` có link.
- [ ] **REST verify** — Click link từ log → 200 với `verified: true`. Check DB: `user.status='active'`, `email_verified_at` không null, `email_verify_tokens.used_at` không null.
- [ ] **REST login trước khi verify** — tạo user mới, login ngay → 403 `EMAIL_NOT_VERIFIED`.
- [ ] **REST login sau khi verify** → 200 + tokens.
- [ ] **REST resend** — POST `/auth/resend-verification` → 200, check log có link mới.
- [ ] **OIDC flow** — mở project-a-demo → "Create an account" → form đầy đủ → submit → thấy `verify-pending.hbs` (không redirect tới demo).
- [ ] **OIDC verify** — mở link trong log → verify → quay lại `/oidc/interaction/<new_uid>` → login thành công → redirect về demo `?code=...`.
- [ ] **OIDC resend** — từ trang verify-pending, click "Resend" → token mới trong log.
---
## 7. Project Structure
```
sso-vietprodev/
├── sso-vietprodev-backend/ # SSO Authorization Server
│ ├── src/
│ │ ├── controllers/ # Route handlers (admin/ + api/v1/)
│ │ ├── oidc/ # OIDC Provider
│ │ │ ├── oidcService.ts # Provider config, findAccount
│ │ │ ├── oidcAdapterService.ts # Postgres adapter
│ │ │ ├── oidcRoutes.ts # /oauth/* routes
│ │ │ └── oidcInteractionsController.ts # login/register/consent
│ │ ├── providers/ # BaseProvider wrappers
│ │ ├── services/ # Business logic
│ │ └── middlewares/ # Auth, validators
│ ├── sql/
│ │ ├── migrations/ # 001-038 (SSO core + facility cleanup)
│ │ └── seeds/ # 100-103 (roles, users, residents)
│ ├── .env / .env.example
│ └── docker-compose.yml
├── project-a-demo/ # Demo app A (port 4001)
│ └── server.js
└── project-b-demo/ # Demo app B (port 4002)
└── server.js
```
---
## 8. Troubleshooting
### OIDC login redirect loop (FIXED)
**Triệu chứng:** Sau khi POST `/oidc/interaction/:uid/login`, browser redirect về `/oauth/authorize?...` rồi lại redirect về `/oidc/interaction/<new_uid>` (login page). Lặp vô hạn.
**Nguyên nhân:** oidc-provider set cookie `_interaction` với `path=/oidc/interaction/:uid` và `_interaction_resume` với `path=/oauth/authorize/:uid`. Khi browser redirect sang `/oauth/authorize?...` (không có UID trong path), cookie `_interaction_resume` không được gửi → oidc-provider tạo session mới → loop.
**Fix:** Thay vì redirect tới `/oauth/authorize?...`, controller `oidcInteractionsController.ts` redirect tới `/oauth/authorize/:uid?...` (oidc-provider's resume route), browser gửi đúng cookie và resume interaction thành công.
### Token exchange returns `invalid_client`
**Nguyên nhân:** oidc-provider so sánh `clientSecret` plaintext với giá trị client gửi lên. Nếu DB lưu bcrypt hash thì compare luôn fail.
**Fix:** Lưu `client_secret_hash` ở dạng **plaintext** (oidc-provider dùng `constantEquals` để so sánh, không hỗ trợ bcrypt). Hoặc override `compareClientSecret` trong client model.
Để cập nhật secret:
```sql
UPDATE clients SET client_secret_hash = 'project-a-demo-secret-123456' WHERE client_id = 'project-a-demo';
UPDATE clients SET client_secret_hash = 'project-b-demo-secret-123456' WHERE client_id = 'project-b-demo';
```
### Server không start
**Port 3001 đã dùng:**
```bash
netstat -ano | findstr :3001
taskkill /F /PID <pid>
```
**OIDC not initialized:**
```bash
docker compose ps postgres # Kiểm tra Postgres healthy
pnpm migrate # Chạy lại migration
pnpm run dev # Restart
```
### OIDC Client lỗi
**`invalid_client`:**
- Sai `client_id` hoặc `client_secret`
- Client `status != 'active'`
**`redirect_uri_mismatch`:**
- `redirect_uri` trong request phải **khớp chính xác** giá trị đã đăng ký
**`invalid_grant` (expired code):**
- Authorization code hết hạn sau ~60 giây. Exchange ngay sau khi nhận redirect.
### Demo app không nhận user info
1. Kiểm tra `access_token` còn hạn (15 phút mặc định)
2. Refresh token: POST `/oauth/token` với `grant_type=refresh_token`
3. Xem console log ở demo app terminal
### Database clean
```bash
# Reset hoàn toàn (MẤT HẾT DATA)
docker compose down postgres
docker volume rm sso-vietprodev-backend_postgres_data
docker compose up -d postgres
pnpm migrate
# Sau đó tạo lại admin user và clients
```
---
## Available Scripts
| Command | Description |
|---------|-------------|
| `pnpm run dev` | Dev server hot reload |
| `pnpm run build` | Build TypeScript → `dist/` |
| `pnpm run start` | Run production build |
| `pnpm migrate` | Apply all migrations |
| `pnpm seed` | Run seeds |
| `pnpm db:setup` | migrate + seed |
| `pnpm docker:dev:detach` | Full stack in Docker |
| `pnpm docker:stop` | Stop all containers |
-- Migration: 040-add-email-verified-at-and-address.sql
-- Description: Add email_verified_at and address columns to users table for
-- email verification flow + full profile support on register.
-- Date: 2026-06-17
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP WITH TIME ZONE,
ADD COLUMN IF NOT EXISTS address TEXT;
-- Index to support filtering verified users and audit queries
CREATE INDEX IF NOT EXISTS idx_users_email_verified_at
ON users (email_verified_at);
......@@ -193,4 +193,7 @@ export const baseConfig: Config = {
webhook: {
token: '',
},
emailVerification: {
tokenTtlHours: 24,
},
};
......@@ -186,6 +186,9 @@ export const EnvSchema = z.object({
// Webhook
WEBHOOK_TOKEN: z.string().default(''),
// Email verification
EMAIL_VERIFICATION_TTL_HOURS: z.coerce.number().default(24),
});
export type EnvVars = z.infer<typeof EnvSchema>;
......@@ -285,6 +285,9 @@ function buildConfig(): Config {
webhook: {
token: envVars.WEBHOOK_TOKEN,
},
emailVerification: {
tokenTtlHours: envVars.EMAIL_VERIFICATION_TTL_HOURS,
},
});
return config as Config;
......
......@@ -122,11 +122,11 @@ const StorageSchema = z.object({
// Email schema
const EmailSchema = z.object({
host: z.string(),
port: z.coerce.number(),
user: z.string(),
pass: z.string(),
from: z.email(),
host: z.string().default(''),
port: z.coerce.number().default(587),
user: z.string().default(''),
pass: z.string().default(''),
from: z.email().default('noreply@vietprodev.com'),
});
// Notifications schema
......@@ -267,6 +267,11 @@ const WebhookSchema = z.object({
token: z.string().default(''),
});
// Email verification token TTL (hours)
const EmailVerificationSchema = z.object({
tokenTtlHours: z.coerce.number().default(24),
});
// Complete config schema
export const ConfigSchema = z.object({
server: z.object({
......@@ -300,6 +305,7 @@ export const ConfigSchema = z.object({
audit: AuditSchema,
runtime: RuntimeConfigSchema,
webhook: WebhookSchema,
emailVerification: EmailVerificationSchema,
});
export type Config = z.infer<typeof ConfigSchema>;
......@@ -58,6 +58,56 @@ export const AUTH_ERRORS: Record<string, ErrorEntry> = {
en: 'This account can only login via OAuth',
},
},
EMAIL_NOT_VERIFIED: {
code: 'EMAIL_NOT_VERIFIED',
httpStatus: 403,
category: ErrorCategory.AUTHENTICATION,
severity: 'medium',
message: {
vi: 'Email chưa được xác thực. Vui lòng kiểm tra hộp thư và xác nhận liên kết xác thực.',
en: 'Email is not verified. Please check your inbox and confirm the verification link.',
},
},
VERIFICATION_TOKEN_INVALID: {
code: 'VERIFICATION_TOKEN_INVALID',
httpStatus: 400,
category: ErrorCategory.AUTHENTICATION,
severity: 'medium',
message: {
vi: 'Liên kết xác thực không hợp lệ hoặc đã được sử dụng.',
en: 'Verification link is invalid or has already been used.',
},
},
VERIFICATION_TOKEN_EXPIRED: {
code: 'VERIFICATION_TOKEN_EXPIRED',
httpStatus: 400,
category: ErrorCategory.AUTHENTICATION,
severity: 'medium',
message: {
vi: 'Liên kết xác thực đã hết hạn. Vui lòng yêu cầu gửi lại email xác thực.',
en: 'Verification link has expired. Please request a new verification email.',
},
},
VERIFICATION_RESEND_COOLDOWN: {
code: 'VERIFICATION_RESEND_COOLDOWN',
httpStatus: 429,
category: ErrorCategory.AUTHENTICATION,
severity: 'low',
message: {
vi: 'Vui lòng đợi một chút trước khi yêu cầu gửi lại email xác thực.',
en: 'Please wait before requesting another verification email.',
},
},
USER_ALREADY_VERIFIED: {
code: 'USER_ALREADY_VERIFIED',
httpStatus: 400,
category: ErrorCategory.AUTHENTICATION,
severity: 'low',
message: {
vi: 'Email này đã được xác thực trước đó. Bạn có thể đăng nhập ngay bây giờ.',
en: 'This email has already been verified. You may log in now.',
},
},
// Token errors
TOKEN_EXPIRED: {
......
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { Req, Res } from '#interfaces/IApi';
import { createRateLimit } from '#middlewares/auth';
import { validateZod } from '#middlewares/validators';
import {
ResendVerificationBodySchema,
type ResendVerificationResponseData,
} from '#contracts/auth/schema';
import { sendSuccess } from '#utils/responseUtils';
import { GenericError } from '#interfaces/error/generic';
import EmailVerificationService from '#services/auth/emailVerificationService';
import { User } from '#models/User';
import Logger from '#utils/logger';
/**
* POST /api/v1/auth/resend-verification
*
* Re-sends the verification email for an unverified account. To avoid
* leaking which emails are registered we always respond with the same shape
* (sent=true) when the email doesn't exist or is already verified — the
* caller can simply tell the user "if your email exists, we sent a link".
*
* Returns the verification expiry and the mail transport mode so debug
* environments know whether the link was actually sent over SMTP or just
* appended to dev-mail.log.
*/
export default (_express: Application) => {
return <Resource>{
post: {
// Tight rate limit to avoid spam. 3 requests / 5 minutes / IP.
middleware: [createRateLimit(5 * 60 * 1000, 3), validateZod(ResendVerificationBodySchema)] as any,
handler: async (req: Req, res: Res) => {
try {
const { email } = req.body;
const normalizedEmail = String(email).trim().toLowerCase();
const user = await User.findOne({ where: { email: normalizedEmail } });
// Generic 200 to avoid email enumeration
if (!user) {
const data: ResendVerificationResponseData = {
sent: true,
expires_at: null,
email_mode: 'fallback',
message:
'Nếu email tồn tại trong hệ thống, một liên kết xác thực mới sẽ được gửi. / If the email exists, a new verification link will be sent.',
};
return sendSuccess(res, data);
}
try {
const result = await EmailVerificationService.getInstance().resendVerificationEmail(user);
const data: ResendVerificationResponseData = {
sent: true,
expires_at: result.expiresAt.toISOString(),
email_mode: result.mode,
message:
'Email xác thực đã được gửi lại. Vui lòng kiểm tra hộp thư. / Verification email re-sent. Please check your inbox.',
};
return sendSuccess(res, data);
} catch (innerErr) {
if (innerErr instanceof GenericError && innerErr.code === 'USER_ALREADY_VERIFIED') {
const data: ResendVerificationResponseData = {
sent: true,
expires_at: null,
email_mode: 'fallback',
message:
'Email đã được xác thực trước đó. Bạn có thể đăng nhập ngay bây giờ. / This email has already been verified. You may log in now.',
};
return sendSuccess(res, data);
}
throw innerErr;
}
} catch (error) {
Logger.warn(`[resend-verification] failed: ${(error as Error).message ?? error}`);
return res.error(error);
}
},
},
};
};
\ No newline at end of file
import { Application } from 'express';
import { Resource } from 'express-automatic-routes';
import { Req, Res } from '#interfaces/IApi';
import { createRateLimit } from '#middlewares/auth';
import { validateQueryZod } from '#middlewares/validators';
import {
VerifyEmailQuerySchema,
VerifyEmailResponseDataSchema,
type VerifyEmailResponseData,
} from '#contracts/auth/schema';
import { sendSuccess } from '#utils/responseUtils';
import { GenericError } from '#interfaces/error/generic';
import EmailVerificationService from '#services/auth/emailVerificationService';
import { AuditLogService } from '#services/audit/auditLogService';
import { randomUUID } from 'crypto';
/**
* GET /api/v1/auth/verify-email?token=...
*
* Public endpoint hit directly from the verification email link. Marks the
* verification token as used and flips the user to `active` + sets
* `email_verified_at`. Returns JSON describing the outcome so a SPA can show
* a "verified!" screen and prompt the user to log in.
*/
export default (_express: Application) => {
return <Resource>{
get: {
middleware: [createRateLimit(5 * 60 * 1000, 30), validateQueryZod(VerifyEmailQuerySchema)] as any,
handler: async (req: Req, res: Res) => {
try {
const { token } = req.query as unknown as { token: string };
const outcome = await EmailVerificationService.getInstance().verifyToken(String(token));
if (outcome.kind === 'not_found') {
throw new GenericError('VERIFICATION_TOKEN_INVALID');
}
if (outcome.kind === 'used') {
throw new GenericError('VERIFICATION_TOKEN_INVALID', undefined, {
vi: 'Liên kết xác thực đã được sử dụng trước đó. Bạn có thể đăng nhập ngay bây giờ.',
en: 'This verification link was already used. You may log in now.',
});
}
if (outcome.kind === 'expired') {
throw new GenericError('VERIFICATION_TOKEN_EXPIRED');
}
// outcome.kind === 'ok' — fire-and-forget audit log
AuditLogService.enqueueSystemAudit({
requestId: randomUUID(),
traceId: randomUUID(),
actorId: outcome.user.id,
actorName:
`${outcome.user.first_name || ''} ${outcome.user.last_name || ''}`.trim() || outcome.user.email,
actorEmail: outcome.user.email,
actorRole: 'USER',
action: 'EMAIL_VERIFIED',
module: 'AUTH',
entityId: outcome.user.id,
entityType: 'User',
description: `Email verified for ${outcome.user.email}`,
severity: 'LOW',
} as any).catch(() => {});
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) {
return res.error(error);
}
},
},
};
};
\ No newline at end of file
import moduleAlias from 'module-alias';
import { FOLDERS } from './constants/index';
import { root } from './root';
import 'dotenv/config';
// ── Dev secrets: set BEFORE any module is loaded ────────────────────────────────
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === undefined) {
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
process.env.JWT_SECRET = 'dev_jwt_secret_key_minimum_32_chars!!';
}
if (!process.env.JWT_REFRESH_SECRET || process.env.JWT_REFRESH_SECRET.length < 32) {
process.env.JWT_REFRESH_SECRET = 'dev_refresh_secret_key_minimum_32_chars!!';
}
if (!process.env.TOKEN_ENCRYPTION_KEY || process.env.TOKEN_ENCRYPTION_KEY.length < 32) {
process.env.TOKEN_ENCRYPTION_KEY = 'dev_encryption_key_exactly_32_chars!';
}
if (!process.env.OIDC_COOKIE_KEYS) {
process.env.OIDC_COOKIE_KEYS = 'dev_cookie_key_at_least_32_chars!!';
}
if (!process.env.DEFAULT_PASSWORD) {
process.env.DEFAULT_PASSWORD = 'Vietpro@123';
}
}
moduleAlias.addAliases({
'#': __dirname,
......
......@@ -38,6 +38,8 @@ export interface UserAttributes {
first_name?: string | null;
last_name?: string | null;
phone?: string | null;
address?: string | null;
email_verified_at?: Date | null;
status?: 'active' | 'inactive' | 'suspended' | 'pending_verification' | null;
created_by?: string | null;
updated_by?: string | null;
......@@ -54,6 +56,8 @@ export type UserOptionalAttributes =
| 'first_name'
| 'last_name'
| 'phone'
| 'address'
| 'email_verified_at'
| 'status'
| 'created_by'
| 'updated_by'
......@@ -69,6 +73,8 @@ export class User extends Model<UserAttributes> implements UserAttributes {
declare first_name?: string | null;
declare last_name?: string | null;
declare phone?: string | null;
declare address?: string | null;
declare email_verified_at?: Date | null;
declare status?: 'active' | 'inactive' | 'suspended' | 'pending_verification' | null;
declare created_by?: string | null;
declare updated_by?: string | null;
......@@ -1130,6 +1136,14 @@ export class User extends Model<UserAttributes> implements UserAttributes {
type: DataTypes.STRING(20),
allowNull: true,
},
address: {
type: DataTypes.TEXT,
allowNull: true,
},
email_verified_at: {
type: DataTypes.DATE,
allowNull: true,
},
status: {
type: DataTypes.ENUM('active', 'inactive', 'suspended', 'pending_verification'),
allowNull: true,
......@@ -1206,6 +1220,10 @@ export class User extends Model<UserAttributes> implements UserAttributes {
name: 'idx_users_deleted_at',
fields: [{ name: 'deleted_at' }],
},
{
name: 'idx_users_email_verified_at',
fields: [{ name: 'email_verified_at' }],
},
{
name: 'idx_users_status',
fields: [{ name: 'status' }],
......
......@@ -31,6 +31,8 @@ import { Bill as _Bill } from './Bill';
import type { BillAttributes, BillCreationAttributes } from './Bill';
import { Building as _Building } from './Building';
import type { BuildingAttributes, BuildingCreationAttributes } from './Building';
import { Client as _Client } from './Client';
import type { ClientAttributes, ClientCreationAttributes } from './Client';
import { ContractFile as _ContractFile } from './ContractFile';
import type { ContractFileAttributes, ContractFileCreationAttributes } from './ContractFile';
import { ContractType as _ContractType } from './ContractType';
......@@ -162,6 +164,7 @@ export {
_BillItem as BillItem,
_Bill as Bill,
_Building as Building,
_Client as Client,
_ContractFile as ContractFile,
_ContractType as ContractType,
_Contract as Contract,
......@@ -243,6 +246,8 @@ export type {
BillCreationAttributes,
BuildingAttributes,
BuildingCreationAttributes,
ClientAttributes,
ClientCreationAttributes,
ContractFileAttributes,
ContractFileCreationAttributes,
ContractTypeAttributes,
......@@ -354,6 +359,7 @@ export function initModels(sequelize: Sequelize) {
const BillItem = _BillItem.initModel(sequelize);
const Bill = _Bill.initModel(sequelize);
const Building = _Building.initModel(sequelize);
const Client = _Client.initModel(sequelize);
const ContractFile = _ContractFile.initModel(sequelize);
const ContractType = _ContractType.initModel(sequelize);
const Contract = _Contract.initModel(sequelize);
......@@ -661,6 +667,7 @@ export function initModels(sequelize: Sequelize) {
BillItem: BillItem,
Bill: Bill,
Building: Building,
Client: Client,
ContractFile: ContractFile,
ContractType: ContractType,
Contract: Contract,
......
/* eslint-disable @typescript-eslint/no-explicit-any */
import express from 'express';
import * as crypto from 'crypto';
import { Transaction } from 'sequelize';
import { OidcService } from './oidcService';
import { AuditLoggerService } from '#audit/auditLoggerService';
import { AUDIT_EVENTS } from '#audit/auditEvents';
import { PasswordService } from '../services/auth/passwordService';
import { PasswordService } from '#services/auth/passwordService';
import EmailVerificationService from '#services/auth/emailVerificationService';
import { User } from '#models/User';
import { Role } from '#models/Role';
import { UserRole } from '#models/UserRole';
import { UserStatus, UserRoleEnum } from '#constants/auth';
import { UserProvider } from '#providers/UserProvider';
import { UserAuthProvider } from '#providers/UserAuthProvider';
import Config from '#config';
import Logger from '#utils/logger';
const router = express.Router();
const MIN_PASSWORD_LENGTH = 12;
const DEFAULT_TTL_HOURS = 24;
// GET /oidc/interaction/:uid — render login or consent page
router.get('/:uid', async (req, res) => {
try {
......@@ -16,7 +30,7 @@ router.get('/:uid', async (req, res) => {
return res.render('consent', {
uid: req.params.uid,
client: details.params.client_id,
csrfToken: (req as any).csrfToken?.() ?? '',
csrfToken: csrfToken(req),
});
}
......@@ -24,19 +38,31 @@ router.get('/:uid', async (req, res) => {
uid: req.params.uid,
client: details.params.client_id,
error: undefined,
csrfToken: (req as any).csrfToken?.() ?? '',
csrfToken: csrfToken(req),
});
} catch {
} catch (err) {
Logger.error('[OIDC] GET /:uid failed', err);
return res.status(500).send('Internal Server Error');
}
});
// GET /oidc/interaction/:uid/register — render registration page
router.get('/:uid/register', async (req, res) => {
const emailPrefill = typeof req.query.email === 'string' ? req.query.email : '';
return res.render('register', {
uid: req.params.uid,
client: '',
prefill: {
email: emailPrefill,
first_name: '',
last_name: '',
phone: '',
address: '',
gender: '',
date_of_birth: '',
},
error: undefined,
csrfToken: (req as any).csrfToken?.() ?? '',
csrfToken: csrfToken(req),
});
});
......@@ -60,84 +86,278 @@ router.post('/:uid/login', async (req, res) => {
} catch (err: any) {
await logAudit(AUDIT_EVENTS.LOGIN_FAILED, undefined, req);
if (err.status === 429) {
if (err?.status === 429) {
return res.status(429).render('login', {
uid: req.params.uid,
client: req.query.client_id as string ?? '',
client: (req.query.client_id as string) ?? '',
error: err.message,
csrfToken: (req as any).csrfToken?.() ?? '',
csrfToken: csrfToken(req),
});
}
return res.status(401).render('login', {
uid: req.params.uid,
client: req.query.client_id as string ?? '',
client: (req.query.client_id as string) ?? '',
error: 'Email or password is invalid',
csrfToken: (req as any).csrfToken?.() ?? '',
csrfToken: csrfToken(req),
});
}
});
// POST /oidc/interaction/:uid/register — create account then login
// POST /oidc/interaction/:uid/register — create account in pending_verification
// status and render the verify-pending screen. Auto-login is intentionally
// skipped; the user must click the link in the verification email first.
router.post('/:uid/register', async (req, res) => {
const { email, username, password, confirmPassword } = req.body as {
const uid = req.params.uid;
const {
email,
username,
password,
confirmPassword,
first_name,
last_name,
phone,
address,
gender,
date_of_birth,
terms_accepted,
} = req.body as {
email?: string;
username?: string;
password?: string;
confirmPassword?: string;
first_name?: string;
last_name?: string;
phone?: string;
address?: string;
gender?: string;
date_of_birth?: string;
terms_accepted?: string | boolean;
};
// Validate required fields
if (!email || !password) {
return res.status(400).render('register', {
uid: req.params.uid,
error: 'Email and password are required',
csrfToken: (req as any).csrfToken?.() ?? '',
const trimmedEmail = (email ?? '').trim().toLowerCase();
const trimmedUsername = username?.trim() || null;
const trimmedFirstName = first_name?.trim() || null;
const trimmedLastName = last_name?.trim() || null;
const trimmedPhone = phone?.trim() || null;
const trimmedAddress = address?.trim() || null;
const trimmedGender = gender?.trim() || null;
const trimmedDob = date_of_birth?.trim() || null;
const termsAccepted = terms_accepted === '1' || terms_accepted === true || terms_accepted === 'on';
const derivedUsername = trimmedUsername ?? trimmedEmail.split('@')[0] ?? null;
const clientId = await getClientIdFromUid(uid);
const renderRegisterError = (error: string) =>
res.status(400).render('register', {
uid,
client: clientId,
prefill: {
email: trimmedEmail,
first_name: trimmedFirstName ?? '',
last_name: trimmedLastName ?? '',
phone: trimmedPhone ?? '',
address: trimmedAddress ?? '',
gender: trimmedGender ?? '',
date_of_birth: trimmedDob ?? '',
},
error,
csrfToken: csrfToken(req),
});
}
// Validate password match
if (!trimmedEmail || !password) {
return renderRegisterError('Email and password are required');
}
if (password !== confirmPassword) {
return res.status(400).render('register', {
uid: req.params.uid,
error: 'Passwords do not match',
csrfToken: (req as any).csrfToken?.() ?? '',
return renderRegisterError('Passwords do not match');
}
if (password.length < MIN_PASSWORD_LENGTH) {
return renderRegisterError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
}
if (!termsAccepted) {
return renderRegisterError('You must accept the terms to create an account');
}
const ttlHours = getVerificationTtlHours();
try {
const userProvider = new UserProvider();
const existing = await userProvider.getAll({
where: { email: trimmedEmail },
page: 1,
pageSize: 1,
});
if (existing.rows.length > 0) {
return renderRegisterError('Email already registered. Please sign in instead.');
}
// Validate password minimum length (OIDC policy)
const minPasswordLength = 12;
if (password.length < minPasswordLength) {
return res.status(400).render('register', {
uid: req.params.uid,
error: `Password must be at least ${minPasswordLength} characters`,
csrfToken: (req as any).csrfToken?.() ?? '',
const passwordHash = await PasswordService.hashPassword(password);
const userId = crypto.randomUUID();
const authId = crypto.randomUUID();
const userRoleId = crypto.randomUUID() as string;
const parsedDob = trimmedDob ? parseIsoDate(trimmedDob) : null;
const transaction: Transaction = await userProvider.transaction();
let createdUserId: ReturnType<typeof crypto.randomUUID> = userId;
let createdUser: User | null = null;
try {
const userInstance = (await userProvider.create(
{
id: userId,
email: trimmedEmail,
username: derivedUsername,
first_name: trimmedFirstName,
last_name: trimmedLastName,
phone: trimmedPhone,
address: trimmedAddress,
date_of_birth: parsedDob as never,
gender: trimmedGender as never,
status: UserStatus.PENDING_VERIFICATION,
email_verified_at: null,
} as never,
{ transaction, skipUniqueCheck: true },
)) as unknown as User;
createdUserId = userInstance.id as ReturnType<typeof crypto.randomUUID>;
createdUser = userInstance;
const userAuthProvider = new UserAuthProvider();
await userAuthProvider.create(
{
id: authId,
user_id: createdUserId,
password_hash: passwordHash,
} as never,
{ transaction, skipUniqueCheck: true },
);
// Assign default 'user' role if it has been seeded. Missing role
// is non-fatal: the user lands without role-bound claims until
// the role is provisioned. Surfacing the warning keeps the gap
// observable without blocking sign-up.
const defaultRole = await Role.findOne({
where: { name: UserRoleEnum.USER },
transaction,
});
if (defaultRole) {
await UserRole.create(
{
id: userRoleId,
user_id: createdUserId,
role_id: defaultRole.id,
is_primary: true,
assigned_at: new Date(),
} as never,
{ transaction },
);
} else {
Logger.warn(`[OIDC register] default role '${UserRoleEnum.USER}' not seeded; user ${createdUserId} has no role`);
}
await transaction.commit();
} catch (txErr) {
await transaction.rollback();
throw txErr;
}
await logAudit(AUDIT_EVENTS.REGISTER_SUCCESS, createdUserId, req);
let devVerifyUrl: string | null = null;
if (createdUser) {
try {
// TODO: Create user via UserProvider
// const derivedUsername = username || email.split('@')[0];
// const passwordHash = await PasswordService.hashPassword(password);
// const user = await userProvider.create({ email, username: derivedUsername, password_hash: passwordHash });
// await logAudit(AUDIT_EVENTS.REGISTER_SUCCESS, user.id, req);
// Placeholder: user registration not yet wired
return res.status(501).render('register', {
uid: req.params.uid,
error: 'User registration is not yet available. Please use an existing account.',
csrfToken: (req as any).csrfToken?.() ?? '',
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(createdUser, ttlHours);
devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;
} catch (mailErr) {
// Email transport is best-effort: token row was already inserted
// inside sendVerificationEmail before the SMTP attempt, so the
// user can still resend or get a fresh link.
Logger.error(
`[OIDC register] Failed to dispatch verification email for user ${createdUserId}. ` +
`Account created but no email was sent.`,
mailErr,
);
}
}
return res.status(200).render('verify-pending', {
uid,
client: clientId,
email: trimmedEmail,
ttlHours,
devVerifyUrl,
error: undefined,
csrfToken: csrfToken(req),
});
} catch (err: any) {
await logAudit(AUDIT_EVENTS.REGISTER_FAILED, undefined, req);
const isUniqueViolation = err.code === '23505';
return res.status(400).render('register', {
uid: req.params.uid,
error: isUniqueViolation
const isUniqueViolation = err?.code === '23505' || err?.original?.code === '23505';
Logger.warn(`[OIDC register] failed: ${err?.message ?? err}`);
return renderRegisterError(
isUniqueViolation
? 'Email or username already exists. Please try again.'
: 'Unable to create account. Please try again.',
csrfToken: (req as any).csrfToken?.() ?? '',
);
}
});
// POST /oidc/interaction/:uid/resend-verification — mint a fresh token and
// re-render the verify-pending screen. The response is identical for known
// and unknown emails to avoid leaking account existence.
router.post('/:uid/resend-verification', async (req, res) => {
const uid = req.params.uid;
const emailRaw = (req.body as { email?: string }).email ?? '';
const trimmedEmail = emailRaw.trim().toLowerCase();
const clientId = await getClientIdFromUid(uid);
const ttlHours = getVerificationTtlHours();
const renderPending = (body: {
email: string;
ttlHours: number;
devVerifyUrl?: string | null;
error?: string;
}) =>
res.status(200).render('verify-pending', {
uid,
client: clientId,
email: body.email,
ttlHours: body.ttlHours,
devVerifyUrl: body.devVerifyUrl ?? null,
error: body.error,
csrfToken: csrfToken(req),
});
if (!trimmedEmail) {
return renderPending({
email: '',
ttlHours,
error: 'Missing email. Please re-open the verification screen.',
});
}
try {
const existing = await User.findOne({ where: { email: trimmedEmail } });
// Intentionally do not leak whether the account exists: any caller
// receives the same "check your inbox" screen, regardless of whether
// the user is unknown, already verified, or not pending.
if (!existing || existing.status !== UserStatus.PENDING_VERIFICATION) {
return renderPending({ email: trimmedEmail, ttlHours });
}
const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existing, ttlHours);
const devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;
return renderPending({
email: trimmedEmail,
ttlHours,
devVerifyUrl,
});
} catch (err) {
Logger.error('[OIDC resend-verification] failed', err);
return renderPending({
email: trimmedEmail,
ttlHours,
error: 'Could not resend verification email. Please try again later.',
});
}
});
......@@ -172,7 +392,8 @@ router.post('/:uid/confirm', async (req, res) => {
const result = { consent: { grantId: await grant.save() } };
return OidcService.interactionFinished(req, res, result, { mergeWithLastSubmission: true });
} catch {
} catch (err) {
Logger.error('[OIDC] POST /:uid/confirm failed', err);
return res.status(500).send('Internal Server Error');
}
});
......@@ -189,32 +410,81 @@ router.post('/:uid/cancel', async (req, res) => {
// ── Helpers ──────────────────────────────────────────────────────────────────
function csrfToken(req: express.Request): string {
return (req as { csrfToken?: () => string }).csrfToken?.() ?? '';
}
function isDevMode(): boolean {
return process.env.NODE_ENV !== 'production';
}
function getVerificationTtlHours(): number {
const fromConfig = (Config as unknown as { emailVerification?: { tokenTtlHours?: number } })
?.emailVerification?.tokenTtlHours;
return typeof fromConfig === 'number' && fromConfig > 0 ? fromConfig : DEFAULT_TTL_HOURS;
}
function parseIsoDate(value: string): Date | null {
if (!value) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* Resolve the OIDC client_id for a given interaction uid. The cookie-based
* `interactionDetails` flow needs the original request, which we do not have
* from a POST handler; instead we go straight to the provider's Interaction
* model which persists the raw params. Failure here is non-fatal — the
* client label is decorative on the verify-pending screen.
*/
async function getClientIdFromUid(uid: string): Promise<string> {
try {
const provider = OidcService.getInstance();
const interaction = await provider.Interaction.find(uid);
const params = (interaction?.params as { client_id?: string } | undefined) ?? undefined;
return params?.client_id ?? '';
} catch (err) {
Logger.warn(
`[OIDC] getClientIdFromUid failed for ${uid}: ${err instanceof Error ? err.message : String(err)}`,
);
return '';
}
}
async function validateCredentials(
email: string,
password: string,
): Promise<{ id: string; email: string; username: string; status: string }> {
// TODO: Wire with UserProvider + PasswordService + brute-force protection
// For now, throw a generic error to indicate not yet implemented
// TODO: Wire with UserProvider + PasswordService + brute-force protection.
// For now, throw a generic error to indicate not yet implemented.
const err: any = new Error('Authentication not yet wired');
err.status = 401;
throw err;
}
async function logAudit(eventType: string, userId?: string, req?: express.Request): Promise<void> {
async function logAudit(eventType: string, userId: string | undefined, req: express.Request): Promise<void> {
if (!Config.audit.enabled) return;
try {
const audit = new AuditLoggerService();
const logEntry: { eventType: string; userId?: string; status: 'success' | 'failed'; ip?: string; userAgent?: string; requestId?: string } = {
const logEntry: {
eventType: string;
userId?: string;
status: 'success' | 'failed';
ip?: string;
userAgent?: string;
requestId?: string;
} = {
eventType,
status: eventType.endsWith('_SUCCESS') ? 'success' : 'failed',
};
if (userId) logEntry.userId = userId;
if (req?.ip) logEntry.ip = req.ip;
if (req?.headers['user-agent']) logEntry.userAgent = req.headers['user-agent'];
if (req.ip) logEntry.ip = req.ip;
if (req.headers['user-agent']) logEntry.userAgent = req.headers['user-agent'];
if ((req as any)?.requestId) logEntry.requestId = (req as any).requestId;
await audit.log(logEntry);
} catch {
// Don't let audit failures break the auth flow
} catch (err) {
// Don't let audit failures break the auth flow.
Logger.warn(`[OIDC] audit log failed for ${eventType}: ${err instanceof Error ? err.message : String(err)}`);
}
}
......
......@@ -3,51 +3,170 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - SSO VietProDev</title>
<title>Create Account - SSO VietProDev</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.register-card { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 100%; max-width: 400px; }
.register-card h2 { margin-bottom: 8px; color: #1a1a2e; font-size: 24px; }
.register-card p { margin-bottom: 24px; color: #666; font-size: 14px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; margin-bottom: 6px; font-weight: 500; color: #333; font-size: 14px; }
.form-group input { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; }
.form-group input:focus { outline: none; border-color: #4f46e5; }
.password-hint { font-size: 12px; color: #888; margin-top: 4px; }
button { width: 100%; padding: 12px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
.register-card { background: #fff; padding: 32px 36px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); width: 100%; max-width: 640px; }
.register-card h2 { margin-bottom: 4px; color: #1a1a2e; font-size: 24px; }
.register-card p.subtitle { margin-bottom: 20px; color: #666; font-size: 14px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 16px; }
.form-group { margin-bottom: 0; }
.form-group.full { grid-column: 1 / -1; }
.form-group label { display: block; margin-bottom: 6px; font-weight: 500; color: #333; font-size: 13px; }
.form-group input, .form-group select { width: 100%; padding: 9px 12px; border: 1px solid #d4d4d8; border-radius: 8px; font-size: 14px; transition: border-color 0.2s; background: #fff; }
.form-group input:focus, .form-group select:focus { outline: none; border-color: #4f46e5; }
.form-group .hint { font-size: 12px; color: #888; margin-top: 4px; }
.terms { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; color: #444; margin: 18px 0 8px; }
.terms input { margin-top: 3px; }
button { width: 100%; padding: 12px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background 0.2s; margin-top: 12px; }
button:hover { background: #4338ca; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
.footer { margin-top: 20px; text-align: center; font-size: 13px; color: #888; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #b91c1c; padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
.footer { margin-top: 18px; text-align: center; font-size: 13px; color: #888; }
.footer a { color: #4f46e5; text-decoration: none; }
@media (max-width: 540px) { .form-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="register-card">
<h2>Create Account</h2>
<p>Register to access the application</p>
<p class="subtitle">
Register to access <strong>{{ client }}</strong> via SSO VietProDev
</p>
{{#if error}}<div class="error">{{ error }}</div>{{/if}}
<form method="POST" action="/oidc/interaction/{{ uid }}/register">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<div class="form-group">
<div class="form-grid">
<div class="form-group full">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email" placeholder="you@example.com">
<input
type="email"
id="email"
name="email"
required
autocomplete="email"
placeholder="you@example.com"
value="{{ prefill.email }}"
>
</div>
<div class="form-group">
<label for="first_name">First name</label>
<input
type="text"
id="first_name"
name="first_name"
autocomplete="given-name"
value="{{ prefill.first_name }}"
>
</div>
<div class="form-group">
<label for="last_name">Last name</label>
<input
type="text"
id="last_name"
name="last_name"
autocomplete="family-name"
value="{{ prefill.last_name }}"
>
</div>
<div class="form-group">
<label for="phone">Phone</label>
<input
type="tel"
id="phone"
name="phone"
autocomplete="tel"
placeholder="+84..."
value="{{ prefill.phone }}"
>
</div>
<div class="form-group">
<label for="gender">Gender</label>
<select id="gender" name="gender" autocomplete="sex">
<option value=""></option>
<option value="male">Male</option>
<option value="female">Female</option>
<option value="other">Other</option>
</select>
</div>
<div class="form-group full">
<label for="address">Address</label>
<input
type="text"
id="address"
name="address"
autocomplete="street-address"
placeholder="Street, city"
value="{{ prefill.address }}"
>
</div>
<div class="form-group">
<label for="date_of_birth">Date of birth</label>
<input
type="date"
id="date_of_birth"
name="date_of_birth"
value="{{ prefill.date_of_birth }}"
>
</div>
<div class="form-group">
<label for="username">Username (optional)</label>
<input type="text" id="username" name="username" autocomplete="username" placeholder="your_username">
<input
type="text"
id="username"
name="username"
autocomplete="username"
placeholder="your_username"
>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="new-password" placeholder="At least 12 characters">
<p class="password-hint">Minimum 12 characters</p>
<input
type="password"
id="password"
name="password"
required
autocomplete="new-password"
placeholder="At least 12 characters"
>
<p class="hint">Minimum 12 characters</p>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input type="password" id="confirmPassword" name="confirmPassword" required autocomplete="new-password" placeholder="Confirm your password">
<label for="confirmPassword">Confirm password</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
required
autocomplete="new-password"
>
</div>
</div>
<button type="submit">Create Account</button>
<label class="terms">
<input type="checkbox" name="terms_accepted" value="1" required>
<span>
Tôi đồng ý với <a href="#" style="color:#4f46e5">Điều khoản dịch vụ</a>
<a href="#" style="color:#4f46e5">Chính sách bảo mật</a> của SSO VietProDev. /
I accept the Terms of Service and Privacy Policy.
</span>
</label>
<button type="submit">Create Account &amp; Send verification</button>
</form>
<div class="footer">
Already have an account? <a href="/oidc/interaction/{{ uid }}">Sign in</a>
</div>
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify your email - SSO VietProDev</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; }
.card { background: #fff; padding: 40px; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); width: 100%; max-width: 520px; }
.card h2 { margin-bottom: 8px; color: #1a1a2e; font-size: 24px; }
.card p { margin-bottom: 16px; color: #444; font-size: 14px; line-height: 1.5; }
.card p.subtitle { color: #666; }
.icon { width: 64px; height: 64px; border-radius: 50%; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; margin: 0 auto 16px; font-size: 32px; }
.email-badge { background: #f3f4f6; padding: 8px 12px; border-radius: 8px; display: inline-block; font-family: monospace; color: #1f2937; margin: 4px 0; }
.dev-block { background: #fffbeb; border: 1px solid #fde68a; color: #92400e; padding: 12px 14px; border-radius: 8px; margin-top: 16px; font-size: 13px; word-break: break-all; }
.dev-block a { color: #b45309; }
.actions { margin-top: 20px; display: flex; flex-direction: column; gap: 10px; }
.actions button, .actions a.button { width: 100%; padding: 11px 12px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; text-align: center; text-decoration: none; transition: background 0.2s; }
.actions button:hover, .actions a.button:hover { background: #4338ca; }
.actions button.secondary { background: #fff; color: #4f46e5; border: 1px solid #c7d2fe; }
.actions button.secondary:hover { background: #eef2ff; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; }
.footer { margin-top: 20px; text-align: center; font-size: 13px; color: #888; }
.footer a { color: #4f46e5; text-decoration: none; }
</style>
</head>
<body>
<div class="card">
<div class="icon">&#9993;</div>
<h2>Verify your email</h2>
<p>We've sent a verification link to <span class="email-badge">{{ email }}</span>.</p>
<p class="subtitle">Open the link to activate your account and continue signing in to <strong>{{ client }}</strong>. The link expires in {{ ttlHours }} hours.</p>
{{#if error}}<div class="error">{{ error }}</div>{{/if}}
{{#if devVerifyUrl}}
<div class="dev-block">
<strong>Dev mode:</strong> email delivery is in fallback mode (logged to server console).
Use this link to verify immediately:
<br><br>
<a href="{{ devVerifyUrl }}">{{ devVerifyUrl }}</a>
</div>
{{/if}}
<div class="actions">
<form method="POST" action="/oidc/interaction/{{ uid }}/resend-verification" style="margin:0;">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
<input type="hidden" name="email" value="{{ email }}">
<button type="submit" class="secondary">Resend verification email</button>
</form>
<a class="button" href="/oidc/interaction/{{ uid }}">Back to sign in</a>
</div>
<div class="footer">
Wrong email? <a href="/oidc/interaction/{{ uid }}/register">Register again</a>
</div>
</div>
</body>
</html>
\ No newline at end of file
......@@ -29,6 +29,7 @@ import PartitionManagementService from '#services/database/partition/partitionMa
import { createAuditStrategy } from '#services/audit/strategies/auditStrategyFactoryService';
import { AuditLogService } from '#services/audit/auditLogService';
import { OidcService } from './oidc/oidcService';
import { MultiPoolService } from '#services/database/multiPoolService';
import oidcRoutes from './oidc/oidcRoutes';
import oidcInteractionsRouter from './oidc/oidcInteractionsController';
// Swagger
......@@ -189,9 +190,20 @@ const // Server functions
// Allow swagger UI inline scripts and fetch only in dev/staging
...(env !== 'production' && {
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
// Allow OIDC form submissions to SSO origin and local dev apps.
formAction: [
"'self'",
'http://localhost:3001',
'http://localhost:4001',
'http://localhost:4002',
'http://127.0.0.1:3001',
'http://127.0.0.1:4001',
'http://127.0.0.1:4002',
],
}),
},
},
......@@ -225,13 +237,14 @@ const // Server functions
// Structured access log — IP, method, path, status, ms, requestId
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
const start = Date.now();
log('ACCESS', `${req.method} ${req.path}`);
res.on('finish', () => {
const ms = Date.now() - start;
const _time = new Date().toLocaleTimeString('en-US', { hour12: false });
// Skip health-check and swagger static asset noise
// Skip noisy paths
if (req.path === '/health') return;
if (/^\/swagger-ui|^\/favicon\.ico/.test(req.path)) return;
if (/^\/swagger(?:-ui)?\//.test(req.path) || /^\/swagger\/index\//.test(req.path)) return;
if (req.path === '/favicon.ico') return;
if (res.statusCode >= 500) {
const accessLog = {
type: 'access',
requestId: (req as any).requestId,
......@@ -242,12 +255,9 @@ const // Server functions
ip: req.ip,
ua: req.headers['user-agent']?.slice(0, 120),
};
log('ACCESS', `${req.method} ${req.path} -> ${res.statusCode} (${ms}ms)`);
if (res.statusCode >= 500) {
Logger.error(JSON.stringify(accessLog));
} else if (Config.logging.mode !== undefined) {
Logger.info(JSON.stringify(accessLog));
}
// 2xx/3xx/4xx: silent in dev (file logger still records them)
});
next();
});
......@@ -395,30 +405,11 @@ const // Server functions
return app;
},
startServer = async (env: Environment) => {
// Environment variables are loaded in root.ts via dotenv
// Fail fast if critical JWT/encryption secrets are missing in production
// Dev secrets are auto-generated in src/index.ts BEFORE any module import
// Production: fail fast if secrets are missing
if (env !== 'development') {
TokenService.validateSecrets();
} else {
// Generate default secrets for development if missing
// eslint-disable-next-line no-restricted-syntax
if (!process.env.JWT_SECRET) {
log('WARN', 'Using default JWT_SECRET - DO NOT USE IN PRODUCTION');
// eslint-disable-next-line no-restricted-syntax
process.env.JWT_SECRET = 'dev_secret_key_minimum_32_characters';
}
// eslint-disable-next-line no-restricted-syntax
if (!process.env.JWT_REFRESH_SECRET) {
log('WARN', 'Using default JWT_REFRESH_SECRET - DO NOT USE IN PRODUCTION');
// eslint-disable-next-line no-restricted-syntax
process.env.JWT_REFRESH_SECRET = 'dev_refresh_secret_key_minimum_32_characters';
}
// eslint-disable-next-line no-restricted-syntax
if (!process.env.TOKEN_ENCRYPTION_KEY) {
log('WARN', 'Using default TOKEN_ENCRYPTION_KEY - DO NOT USE IN PRODUCTION');
// eslint-disable-next-line no-restricted-syntax
process.env.TOKEN_ENCRYPTION_KEY = 'dev_encryption_key_exactly_32_characters!!';
}
TokenService.validateSecrets();
}
......@@ -453,6 +444,15 @@ const // Server functions
log('WARN', `OIDC provider failed: ${oidcErr}`);
}
// Auto-load project DB connections from project_db_connections table.
// Must happen after OIDC init so any /admin/db-connections activity in
// tests can be exercised, but before the server starts listening.
try {
await MultiPoolService.autoLoadPools();
} catch (poolErr) {
log('WARN', `autoLoadPools failed: ${poolErr}`);
}
await generateSwagger();
serveSwagger(app, storagePath);
log('OK', 'OpenAPI ready');
......@@ -564,6 +564,13 @@ const // Server functions
} catch (mongoErr) {
log('ERR', `Error disconnecting MongoDB: ${mongoErr}`);
}
// Close MultiPool pools (project DB connections)
try {
await MultiPoolService.closeAll();
log('OK', 'Project pools closed');
} catch (poolErr) {
log('ERR', `Error closing project pools: ${poolErr}`);
}
// Stop OutboxPoller (moved to separate worker process)
/*
try {
......@@ -614,6 +621,13 @@ const // Server functions
log('ERR', `OIDC provider failed: ${oidcErr}`);
}
// Auto-load project DB connections from project_db_connections table.
try {
await MultiPoolService.autoLoadPools();
} catch (poolErr) {
log('WARN', `autoLoadPools failed: ${poolErr}`);
}
// TEMP: Disabled swagger generation to debug startup hang
if (env === 'staging') await generateSwagger();
......@@ -718,6 +732,13 @@ const // Server functions
} catch (mongoErr) {
log('ERR', `Error disconnecting MongoDB: ${mongoErr}`);
}
// Close MultiPool pools (project DB connections)
try {
await MultiPoolService.closeAll();
log('OK', 'Project pools closed');
} catch (poolErr) {
log('ERR', `Error closing project pools: ${poolErr}`);
}
// Stop OutboxPoller (moved to separate worker process)
/*
try {
......
import crypto from 'crypto';
import { Transaction } from 'sequelize';
import sequelize from '#services/database/sequelize/sequelizeService';
import { EmailVerifyToken } from '#models/EmailVerifyToken';
import { User } from '#models/User';
import MailService from '#services/notification/notificationEmailService';
import { getEmailVerificationEmail } from '#templates/email/emailVerification';
import { GenericError } from '#interfaces/error/generic';
import Config from '#config';
const TOKEN_BYTES = 32; // 64 hex chars — matches `email_verify_tokens.token_hash` CHAR(64)
const DEFAULT_TTL_HOURS = 24;
const HASH_ALGO = 'sha256';
function hashToken(plain: string): string {
return crypto.createHash(HASH_ALGO).update(plain).digest('hex');
}
export interface CreateTokenResult {
token: string; // plain token — only returned to the caller, never stored
expiresAt: Date;
}
export type VerifyTokenOutcome =
| { kind: 'ok'; user: User }
| { kind: 'expired' }
| { kind: 'used' }
| { kind: 'not_found' };
class EmailVerificationService {
private static instance: EmailVerificationService;
static getInstance(): EmailVerificationService {
EmailVerificationService.instance ??= new EmailVerificationService();
return EmailVerificationService.instance;
}
/**
* Create a fresh verification token for a user. Old (unused) tokens for the
* same user are invalidated so only the most recent email is honoured.
*/
async createToken(userId: string, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS): Promise<CreateTokenResult> {
const plain = crypto.randomBytes(TOKEN_BYTES).toString('hex');
const tokenHash = hashToken(plain);
const expiresAt = new Date(Date.now() + ttlHours * 60 * 60 * 1000);
await sequelize.transaction(async (tx: Transaction) => {
// Invalidate any outstanding tokens so a single user can only verify
// via the latest email we sent out.
await EmailVerifyToken.update(
{ used_at: new Date() },
{
where: { user_id: userId, used_at: null },
transaction: tx,
}
);
await EmailVerifyToken.create(
{
id: crypto.randomUUID(),
user_id: userId,
token_hash: tokenHash,
expires_at: expiresAt,
},
{ transaction: tx }
);
});
return { token: plain, expiresAt };
}
/**
* Verify a plain token from a verification link. Marks the token used and
* flips the user to `active` + `email_verified_at` on success.
*/
async verifyToken(plain: string): Promise<VerifyTokenOutcome> {
const tokenHash = hashToken(plain);
return sequelize.transaction(async (tx: Transaction) => {
const record = await EmailVerifyToken.findOne({
where: { token_hash: tokenHash },
transaction: tx,
});
if (!record) return { kind: 'not_found' };
if (record.used_at) return { kind: 'used' };
if (record.expires_at.getTime() < Date.now()) return { kind: 'expired' };
const user = await User.findByPk(record.user_id, { transaction: tx });
if (!user) return { kind: 'not_found' };
record.used_at = new Date();
await record.save({ transaction: tx });
user.email_verified_at = new Date();
user.status = 'active';
await user.save({ transaction: tx });
return { kind: 'ok', user };
});
}
/**
* Build the verification link for a given plain token. Centralised so the
* route/path is defined in exactly one place.
*/
buildVerificationUrl(plain: string): string {
const base = (Config.server.backendUrl || 'http://localhost:3001').replace(/\/+$/, '');
return `${base}/api/v1/auth/verify-email?token=${encodeURIComponent(plain)}`;
}
/**
* Send a verification email for a newly-created user. In dev (no SMTP
* configured) the link is appended to `dev-mail.log` so it can be clicked
* manually.
*/
async sendVerificationEmail(user: User, ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS): Promise<{ token: string; expiresAt: Date }> {
const { token, expiresAt } = await this.createToken(user.id, ttlHours);
const url = this.buildVerificationUrl(token);
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim() || null;
const html = getEmailVerificationEmail({
email: user.email,
fullName,
verificationUrl: url,
expiresInHours: ttlHours,
});
await MailService.getInstance().sendmail({
from: Config.email.from,
to: user.email,
subject: '[SSO VietProDev] Xác thực email / Verify your email',
text: `Xin chào ${user.email},\n\nVui lòng truy cập liên kết sau để xác thực email (hết hạn sau ${ttlHours} giờ):\n${url}\n\nNếu bạn không đăng ký, vui lòng bỏ qua email này.`,
html,
});
return { token, expiresAt };
}
/**
* Resend a verification email for an existing user. If the user is already
* verified this throws `USER_ALREADY_VERIFIED`. If an unexpired token was
* issued very recently we surface `VERIFICATION_RESEND_COOLDOWN` so the
* client can show a friendly "wait a moment" message.
*
* Returns the new token's expiry and the mail transport mode so the
* controller can echo them back to the caller.
*/
async resendVerificationEmail(
user: User,
ttlHours = Config.emailVerification?.tokenTtlHours ?? DEFAULT_TTL_HOURS,
cooldownSeconds = 60,
): Promise<{ expiresAt: Date; mode: 'smtp' | 'fallback' }> {
if (user.email_verified_at) {
throw new GenericError('USER_ALREADY_VERIFIED');
}
// Cooldown: if the most recent unused token was created within the
// cooldown window, refuse to spam the user's inbox.
const recent = await EmailVerifyToken.findOne({
where: { user_id: user.id, used_at: null },
order: [['expires_at', 'DESC']],
});
if (recent) {
// 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) {
const url = this.buildVerificationUrl(
// We can't return the original plaintext token (only the hash is stored).
// Generate a new token to be safe; the previous token is invalidated
// inside createToken().
(await this.createToken(user.id, ttlHours)).token,
);
const result = await this.dispatchMail(user, url, ttlHours);
return { expiresAt: recent.expires_at, mode: result };
}
}
const { token, expiresAt } = await this.createToken(user.id, ttlHours);
const url = this.buildVerificationUrl(token);
const mode = await this.dispatchMail(user, url, ttlHours);
return { expiresAt, mode };
}
private async dispatchMail(
user: User,
url: string,
ttlHours: number,
): Promise<'smtp' | 'fallback'> {
const fullName = [user.first_name, user.last_name].filter(Boolean).join(' ').trim() || null;
const html = getEmailVerificationEmail({
email: user.email,
fullName,
verificationUrl: url,
expiresInHours: ttlHours,
});
const result = await MailService.getInstance().sendmail({
from: Config.email.from,
to: user.email,
subject: '[SSO VietProDev] Xác thực email / Verify your email',
text: `Xin chào ${user.email},\n\nVui lòng truy cập liên kết sau để xác thực email (hết hạn sau ${ttlHours} giờ):\n${url}\n\nNếu bạn không đăng ký, vui lòng bỏ qua email này.`,
html,
});
return result.mode;
}
}
export default EmailVerificationService;
import nodemailer from 'nodemailer';
import { SentMessageInfo } from 'nodemailer/lib/smtp-transport';
import { Options } from 'nodemailer/lib/mailer';
import * as fs from 'fs';
import * as path from 'path';
import Config from '#config';
interface SendResult {
mode: 'smtp' | 'fallback';
info: SentMessageInfo | string;
}
class MailService {
private static instance: MailService;
transporter: nodemailer.Transporter<SentMessageInfo>;
transporter: nodemailer.Transporter<SentMessageInfo> | null;
private fallbackLogPath: string;
private constructor() {
// Use SMTP only when host + credentials are configured. Otherwise fall back
// to a console/file logger so dev environments can still test the email
// verification flow without setting up Gmail/SendGrid.
const smtpConfigured =
Config.email.host &&
Config.email.host !== 'smtp.example.com' &&
Config.email.user &&
Config.email.pass;
if (smtpConfigured) {
this.transporter = nodemailer.createTransport({
host: Config.email.host,
port: Config.email.port,
secure: false, // true for 465, false for other ports
secure: false,
auth: {
user: Config.email.user,
pass: Config.email.pass,
},
});
} else {
this.transporter = null;
}
// Fallback log goes next to the rest of dev artefacts so it's easy to find.
this.fallbackLogPath = path.resolve(process.cwd(), 'dev-mail.log');
}
static getInstance(): MailService {
......@@ -24,9 +48,42 @@ class MailService {
return MailService.instance;
}
async sendmail(mailOptions: Options) {
return this.transporter.sendMail(mailOptions);
private logFallback(mailOptions: Options): string {
const entry = [
'====== DEV MAIL (no SMTP configured) ======',
`At: ${new Date().toISOString()}`,
`From: ${mailOptions.from ?? Config.email.from}`,
`To: ${(mailOptions.to ?? '').toString()}`,
`Subj: ${mailOptions.subject ?? '(no subject)'}`,
'----- TEXT -----',
mailOptions.text ?? '(no text body)',
'----- HTML -----',
mailOptions.html ?? '(no html body)',
'=============================================',
'',
].join('\n');
// eslint-disable-next-line no-console
console.log(`\n[email fallback] ${(mailOptions.to ?? '').toString()} see dev-mail.log`);
try {
fs.appendFileSync(this.fallbackLogPath, entry, 'utf8');
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[email fallback] failed to append dev-mail.log:', err);
}
return entry;
}
async sendmail(mailOptions: Options): Promise<SendResult> {
if (this.transporter) {
const info = await this.transporter.sendMail(mailOptions);
return { mode: 'smtp', info };
}
const logged = this.logFallback(mailOptions);
return { mode: 'fallback', info: logged };
}
}
export default MailService;
export type { SendResult };
interface EmailVerificationData {
email: string;
fullName?: string | null;
verificationUrl: string;
expiresInHours: number;
}
/**
* Renders the email body sent to a newly-registered user with a single-use
* verification link. The user cannot log in (or use the SSO) until they
* click this link and the corresponding `email_verify_tokens` row is marked
* as `used_at`.
*/
export function getEmailVerificationEmail(data: EmailVerificationData): string {
const { email, fullName, verificationUrl, expiresInHours } = data;
const greetingName = fullName?.trim() || email;
const issuedAt = new Date().toLocaleString('vi-VN');
const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toLocaleString('vi-VN');
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Xác thực email - Email Verification</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f4f4f7; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.header h1 { margin: 0; font-size: 24px; }
.content { background: white; padding: 30px; border-radius: 0 0 10px 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
.btn { display: inline-block; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white !important; padding: 14px 36px; text-decoration: none; border-radius: 6px; font-weight: bold; margin: 20px 0; }
.warning { background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px; }
.fallback-link { background: #f4f4f7; padding: 12px; border-radius: 4px; word-break: break-all; font-family: 'Courier New', monospace; font-size: 12px; color: #555; }
.footer { text-align: center; margin-top: 30px; color: #666; font-size: 12px; }
.info-row { margin: 10px 0; }
.label { font-weight: bold; color: #555; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>SSO VietProDev</h1>
<p style="margin: 5px 0 0 0;">Xác thực địa chỉ email</p>
</div>
<div class="content">
<p><strong>Xin chào ${greetingName},</strong></p>
<p>
<strong>Tiếng Việt:</strong><br>
Cảm ơn bạn đã đăng ký tài khoản SSO VietProDev. Để hoàn tất quá trình đăng ký và kích hoạt tài khoản,
vui lòng nhấp vào nút bên dưới để xác thực địa chỉ email của bạn.
</p>
<p>
<strong>English:</strong><br>
Thank you for registering a SSO VietProDev account. To finish the sign-up process and activate your account,
please click the button below to verify your email address.
</p>
<div style="text-align: center;">
<a href="${verificationUrl}" class="btn">Xác thực email / Verify email</a>
</div>
<p>
Hoặc sao chép liên kết sau vào trình duyệt / Or copy this link into your browser:
</p>
<div class="fallback-link">${verificationUrl}</div>
<div class="warning">
<p style="margin: 0 0 8px 0;">
<strong>⚠️ Lưu ý quan trọng / Important Notice:</strong>
</p>
<ul style="margin: 0; padding-left: 20px;">
<li>Liên kết sẽ hết hạn sau <strong>${expiresInHours} giờ</strong> / Link expires in <strong>${expiresInHours} hours</strong></li>
<li>Bạn cần xác thực email trước khi có thể đăng nhập / You must verify before you can log in</li>
<li>Nếu bạn không thực hiện đăng ký này, vui lòng bỏ qua email / If you did not sign up, please ignore this email</li>
<li>Không chia sẻ liên kết này với người khác / Do not share this link with others</li>
</ul>
</div>
<div class="info-row">
<span class="label">Email đăng ký / Registered email:</span> ${email}
</div>
<div class="info-row">
<span class="label">Thời gian gửi / Issued at:</span> ${issuedAt}
</div>
<div class="info-row">
<span class="label">Hết hạn lúc / Expires at:</span> ${expiresAt}
</div>
</div>
<div class="footer">
<p>© ${new Date().getFullYear()} SSO VietProDev. All rights reserved.</p>
<p>Đây là email tự động, vui lòng không trả lời / This is an automated message, please do not reply.</p>
</div>
</div>
</body>
</html>
`;
}
import crypto from 'crypto';
import EmailVerificationService from '#services/auth/emailVerificationService';
import { EmailVerifyToken } from '#models/EmailVerifyToken';
import { User } from '#models/User';
import { GenericError } from '#interfaces/error/generic';
// Mock the sequelize service so the test can exercise EmailVerificationService
// without booting a real database connection.
jest.mock('#services/database/sequelize/sequelizeService', () => {
const transaction = { commit: jest.fn().mockResolvedValue(undefined), rollback: jest.fn().mockResolvedValue(undefined) };
return {
__esModule: true,
default: {
transaction: jest.fn().mockImplementation(async (fn: (tx: typeof transaction) => Promise<unknown>) => fn(transaction)),
},
};
});
// Mock the models — we want to control persistence outcomes from the test
// cases, not the real Sequelize layer.
jest.mock('#models/EmailVerifyToken', () => {
const Model = jest.fn();
(Model as unknown as { update: jest.Mock }).update = jest.fn();
(Model as unknown as { create: jest.Mock }).create = jest.fn();
(Model as unknown as { findOne: jest.Mock }).findOne = jest.fn();
return { EmailVerifyToken: Model };
});
jest.mock('#models/User', () => {
const Model = jest.fn();
(Model as unknown as { findByPk: jest.Mock }).findByPk = jest.fn();
return { User: Model };
});
jest.mock('#services/notification/notificationEmailService', () => ({
__esModule: true,
default: {
getInstance: () => ({
sendmail: jest.fn().mockResolvedValue({ mode: 'fallback', info: null }),
}),
},
}));
jest.mock('#utils/logger', () => ({
__esModule: true,
default: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));
const mockedSequelize = jest.requireMock('#services/database/sequelize/sequelizeService').default;
const mockedEmailVerifyToken = EmailVerifyToken as unknown as {
update: jest.Mock;
create: jest.Mock;
findOne: jest.Mock;
};
const mockedUser = User as unknown as { findByPk: jest.Mock };
describe('EmailVerificationService', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('buildVerificationUrl', () => {
it('builds a URL against Config.server.backendUrl with the token query', () => {
const service = EmailVerificationService.getInstance();
const url = service.buildVerificationUrl('abc123');
expect(url).toMatch(/\/api\/v1\/auth\/verify-email\?token=abc123$/);
});
it('strips trailing slashes from the backend URL', () => {
const originalBackendUrl = process.env.BACKEND_URL;
process.env.BACKEND_URL = 'http://example.test///';
jest.isolateModules(() => {
// Re-import in the isolated module registry to pick up env change.
const ServiceModule = require('#services/auth/emailVerificationService');
const service = (ServiceModule.default.getInstance() as InstanceType<typeof ServiceModule.default>);
const url = service.buildVerificationUrl('zzz');
expect(url.startsWith('http://example.test/api/v1/auth/verify-email?token=zzz')).toBe(true);
});
process.env.BACKEND_URL = originalBackendUrl;
});
});
describe('createToken', () => {
it('persists a SHA-256 hash of the token and returns the plaintext', async () => {
const created: Array<Record<string, unknown>> = [];
mockedEmailVerifyToken.update.mockResolvedValueOnce([1]);
mockedEmailVerifyToken.create.mockImplementationOnce((row: Record<string, unknown>) => {
created.push(row);
return Promise.resolve(row);
});
const service = EmailVerificationService.getInstance();
const result = await service.createToken('user-id-1', 24);
expect(result.token).toMatch(/^[0-9a-f]{64}$/);
expect(result.expiresAt.getTime()).toBeGreaterThan(Date.now());
expect(mockedEmailVerifyToken.update).toHaveBeenCalledWith(
{ used_at: expect.any(Date) },
{ where: { user_id: 'user-id-1', used_at: null }, transaction: expect.anything() },
);
expect(created).toHaveLength(1);
const row = created[0]!;
// hash must be the SHA-256 of the returned plaintext
const expectedHash = crypto.createHash('sha256').update(result.token).digest('hex');
expect(row.token_hash).toBe(expectedHash);
expect(row.user_id).toBe('user-id-1');
});
});
describe('verifyToken', () => {
it('returns not_found when no row exists', async () => {
mockedEmailVerifyToken.findOne.mockResolvedValueOnce(null);
const service = EmailVerificationService.getInstance();
const outcome = await service.verifyToken('whatever');
expect(outcome).toEqual({ kind: 'not_found' });
});
it('returns used when the row already has used_at set', async () => {
mockedEmailVerifyToken.findOne.mockResolvedValueOnce({
used_at: new Date(),
expires_at: new Date(Date.now() + 60_000),
user_id: 'u-1',
});
const service = EmailVerificationService.getInstance();
const outcome = await service.verifyToken('whatever');
expect(outcome).toEqual({ kind: 'used' });
});
it('returns expired when the row is past its expires_at', async () => {
mockedEmailVerifyToken.findOne.mockResolvedValueOnce({
used_at: null,
expires_at: new Date(Date.now() - 1_000),
user_id: 'u-1',
});
const service = EmailVerificationService.getInstance();
const outcome = await service.verifyToken('whatever');
expect(outcome).toEqual({ kind: 'expired' });
});
it('returns ok and marks the user verified on a fresh token', async () => {
const save = jest.fn().mockResolvedValue(undefined);
const userInstance = {
email_verified_at: null as Date | null,
status: 'pending_verification' as string,
save,
};
const record = {
used_at: null as Date | null,
expires_at: new Date(Date.now() + 60_000),
user_id: 'u-2',
save: jest.fn().mockResolvedValue(undefined),
};
mockedEmailVerifyToken.findOne.mockResolvedValueOnce(record);
mockedUser.findByPk.mockResolvedValueOnce(userInstance);
const service = EmailVerificationService.getInstance();
const outcome = await service.verifyToken('whatever');
expect(outcome).toEqual({ kind: 'ok', user: userInstance });
expect(userInstance.email_verified_at).toBeInstanceOf(Date);
expect(userInstance.status).toBe('active');
expect(record.used_at).toBeInstanceOf(Date);
expect(record.save).toHaveBeenCalledWith({ transaction: expect.anything() });
expect(userInstance.save).toHaveBeenCalledWith({ transaction: expect.anything() });
// Ensure the transaction wrapper was actually used.
expect(mockedSequelize.transaction).toHaveBeenCalled();
});
});
describe('resendVerificationEmail', () => {
it('throws USER_ALREADY_VERIFIED for users with email_verified_at set', async () => {
const service = EmailVerificationService.getInstance();
const user = { id: 'u-3', email_verified_at: new Date() } as never;
await expect(service.resendVerificationEmail(user)).rejects.toBeInstanceOf(GenericError);
});
});
});
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