GĐ07 — NestJS: DI, guards/pipes/interceptors, auth đầy đủ (Dự án 3: portfolio)

Ghi chú học cho FE (JS/TS mạnh) chuyển sang BE. Mỗi khái niệm: định nghĩa → tại sao quan trọng → cơ chế → ví dụ code ngắn → pitfall thực tế. Tư duy chính cần "unlearn" từ FE: ở BE bạn không render UI, bạn thiết kế contract (request → validate → business logic → response), và mọi thứ đều là dependency được inject, không phải import trực tiếp.

Kiểm chứng ngày 2026-10-05: NestJS hiện là major 12 (ra 2026-08-27; chạy thử với @nestjs/common 12.1.2): ESM-only, cần Node 20.19+/22.12+/24+, test runner mặc định là Vitest (snippet test bên dưới viết bằng Jest; với Vitest đổi jest.fn() thành vi.fn()). Nest không có @SkipInterceptor (đã tìm trong @nestjs/common và @nestjs/core 12.1.2): dùng SetMetadata + Reflector (mục 9). Refresh token rotation cần jti (mục 11). NODE_ENV ∈ development/test/production (mục 12).

Kiểm chứng ngày 2026-10-05 (bổ sung): các đoạn code mới về guard toàn cục, @CurrentUser, filter, timeout, JwtModule.registerAsync, Throttler, vòng đời, cảnh báo DLQ và OAuth (lõi đăng nhập dùng provider giả) đã biên dịch bằng tsc --strict trên Nest 12.1.2, @nestjs/throttler 6.7.1, BullMQ 6.3.11, passport-oauth2 1.8.0 và passport-google-oauth20 2.0.0, rồi chạy qua supertest trong thư mục tạm; kết quả nằm ở từng mục. Nguồn: Nest rate limiting, Nest performance, RFC 9700 (mục 2.1.1, 4.7.1), OIDC Core, Google OpenID Connect, GitHub: authorizing OAuth apps, GitHub: user emails. Chưa xác minh: không gọi Google/GitHub thật (phần đổi code lấy danh tính chỉ qua tsc, chưa chạy); trang web-server của Google không nhắc PKCE nên việc Google nhận code_challenge ở luồng này chưa được xác minh; gói @nestjs/authentication (bản 0.0.1 trên npm, được trang Nest authentication mô tả) chưa chạy thử nên bài này giữ Passport; Fastify adapter chưa tự đo.


1. NestJS là gì — vì sao dùng thay Express thuần#

Định nghĩa. NestJS là một framework Node.js (TypeScript-first) để xây server-side app. Nó opinionated: áp một kiến trúc chuẩn (module + controller + provider) lấy cảm hứng từ Angular. Nest không tự viết HTTP server — nó chạy trên một adapter: mặc định là Express, có thể đổi sang Fastify (tài liệu Nest nói gần gấp đôi trong benchmark của họ; chưa tự đo, phụ thuộc workload).

Tại sao quan trọng. Express thuần cho bạn tự do tuyệt đối → mỗi team tự bịa cấu trúc thư mục, cách wire dependency, cách validate. Sau 6 tháng codebase thành mì spaghetti. Nest ép một khung xương chung: người mới đọc repo Nest nào cũng biết logic nằm ở đâu. So sánh với FE: Express = React thuần với useState khắp nơi; Nest = Next.js có convention rõ ràng.

Cơ chế.

  • Nest build một application graph lúc bootstrap: quét decorators (@Module, @Injectable, @Controller), dựng IoC container, resolve toàn bộ dependency, rồi mount route lên adapter (Express/Fastify).
  • Request đi qua pipeline: Middleware → Guards → Interceptors (pre) → Pipes → Handler → Interceptors (post) → Exception filters.
  • TS-first: dùng decorators (experimentalDecorators) + metadata reflection (reflect-metadata) để đọc type ở runtime → nền tảng của DI và validation.

Ví dụ code.

typescriptReady
// main.ts — bootstrapimport { NestFactory } from '@nestjs/core';import { AppModule } from './app.module';async function bootstrap() {  const app = await NestFactory.create(AppModule); // mặc định Express  await app.listen(3000);}bootstrap();

Pitfall thực tế. Nhiều FE nghĩ "Nest nặng, dùng Express cho nhanh". Đúng cho script 1 file, nhưng app thật (auth, DB, nhiều team) thì Nest tiết kiệm hơn nhiều. Ngược lại: đừng chọn Fastify adapter chỉ vì "nhanh" nếu bạn phụ thuộc middleware Express (một số lib chỉ support Express) — kiểm tra tương thích trước.

Từ Express sang Nest: bảng chuyển đổi#

Bạn đã dựng Express ở GĐ04; mỗi thứ ở đó có chỗ đặt riêng trong Nest.

Express (GĐ04)NestGhi chú
router.get(...) + handler@Controller + @Getcontroller chỉ nhận request, gọi service
app.use(fn) toàn cụcNestMiddleware + configure(consumer)chạy trước guard
middleware requireAuthGuard (CanActivate)trả false ra 403, ném UnauthorizedException ra 401
req.user = ... rồi đọc req.uservalidate() của strategy + @CurrentUser()mục 8
validate bằng Zod trong handlerValidationPipe + DTOmục 6, 7
error middleware 4 tham sốExceptionFiltermục 10
res.json(wrap(data)) ở từng routeInterceptormục 9
new Service(deps) tự tay ở app.tsDI containermục 4, 5

Middleware và vòng đời module#

Thứ tự chạy mỗi request đã đo trên Nest 12.1.2: middleware, guard, interceptor (trước), handler. Pipe chỉ chạy cho tham số của handler, nên handler không có tham số thì không có gì để pipe xử lý.

typescriptReady
@Injectable()export class RequestIdMiddleware implements NestMiddleware {  use(req: Request, res: Response, next: NextFunction) {    res.setHeader('x-request-id', req.headers['x-request-id'] ?? randomUUID())    next()  }}@Module({ /* ... */ })export class AppModule implements NestModule {  configure(consumer: MiddlewareConsumer) {    consumer.apply(RequestIdMiddleware).forRoutes('*splat')   // '*' cũng chạy trên Nest 12.1.2  }}

Provider có thể khai báo hook vòng đời. Thứ tự đã đo: onModuleInit, onApplicationBootstrap lúc khởi động; khi app.close() hoặc nhận SIGTERM (cần app.enableShutdownHooks()): onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown. Mở kết nối ở onModuleInit, đóng ở onModuleDestroy hoặc onApplicationShutdown. Đã chạy (tsc --strict, supertest).


2. Module — @Module#

Định nghĩa. Module là đơn vị đóng gói một domain/feature. Mỗi Nest app có ít nhất một root module (AppModule); các feature (users, auth, payment...) thành feature module riêng. @Module() nhận 4 mảng:

  • controllers: khai báo controller thuộc module.
  • providers: service/repository... module này tự tạo & dùng nội bộ.
  • imports: module khác mà module này cần (để dùng provider chúng export).
  • exports: provider mà module này cho phép module khác dùng.

Tại sao quan trọng. Module định nghĩa ranh giới encapsulation của DI. Một provider không tự động dùng được ở mọi nơi — nó chỉ khả dụng trong module khai báo, trừ khi được export và module kia import. Đây là cơ chế chống "mọi thứ nối với mọi thứ".

Cơ chế. Khi bootstrap, Nest duyệt cây module bắt đầu từ root, dựng scope DI cho từng module. Provider trong exports được "nâng" lên để module import thấy được. Import mang tính transitive qua re-export chứ không tự lan: A export X, B import A và muốn cho C dùng X thì B phải re-export A/X.

Ví dụ code.

typescriptReady
// users/users.module.ts@Module({  controllers: [UsersController],  providers: [UsersService],  exports: [UsersService], // cho AuthModule dùng})export class UsersModule {}// auth/auth.module.ts@Module({  imports: [UsersModule], // giờ AuthService inject được UsersService  providers: [AuthService],})export class AuthModule {}

Pitfall thực tế. Lỗi kinh điển: Nest can't resolve dependencies of AuthService (?). Please make sure UsersService is available... → nguyên nhân 90% là quên exports: [UsersService] trong UsersModule, hoặc quên imports: [UsersModule]. Đừng "sửa" bằng cách khai báo UsersService lại trong providers của AuthModule — làm vậy sẽ tạo 2 instance khác nhau, state không share.


3. Controller — routing layer#

Định nghĩa. Controller nhận HTTP request và trả response. @Controller('users') gắn prefix path; các method decorator (@Get, @Post, @Patch, @Delete) map HTTP verb + sub-path. Param decorators trích dữ liệu: @Param, @Query, @Body, @Headers, @Req.

Tại sao quan trọng. Controller là lớp mỏng: chỉ nhận input, gọi service, trả kết quả. Nó là "adapter" giữa HTTP và business logic. Giữ nó mỏng giúp business logic tái dùng được (từ CLI, cron, queue... không chỉ HTTP).

Cơ chế. Nest đọc metadata từ decorator để build routing table lúc bootstrap. Return value của handler tự động được serialize thành JSON + status 200 (201 cho @Post). Trả Promise/Observable cũng được — Nest tự await.

Ví dụ code.

typescriptReady
@Controller('users')export class UsersController {  constructor(private readonly users: UsersService) {}  @Get()                       // GET /users?limit=10  findAll(@Query('limit') limit: string) {    return this.users.findAll(Number(limit));  }  @Get(':id')                  // GET /users/42  findOne(@Param('id') id: string) {    return this.users.findOne(id);  }  @Post()                      // POST /users -> 201  create(@Body() dto: CreateUserDto) {    return this.users.create(dto);  }}

Pitfall thực tế. Nhồi business logic (query DB, gọi API ngoài) thẳng vào controller → không test được, không tái dùng. Thứ hai: thứ tự route matter — @Get('me') phải đặt trước @Get(':id'), nếu không me bị nuốt bởi param :id.


4. Provider & Service#

Định nghĩa. Provider là bất cứ class nào Nest có thể inject (@Injectable()): service, repository, factory, helper, thậm chí một value. Service là loại provider phổ biến nhất — nơi chứa business logic.

Tại sao quan trọng. Tách logic khỏi controller cho ba lợi ích: (1) test độc lập không cần HTTP; (2) tái dùng logic ở nhiều controller/queue/cron; (3) một chỗ duy nhất để sửa rule nghiệp vụ. Đây là "Single Responsibility" ở tầng kiến trúc.

Cơ chế. @Injectable() đánh dấu class là provider để IoC container quản lý. Khai báo nó trong providers của module → container tạo một instance (singleton) và inject vào bất cứ ai khai báo nó ở constructor.

Ví dụ code.

typescriptReady
@Injectable()export class UsersService {  constructor(private readonly repo: UserRepository) {}  async findOne(id: string) {    const user = await this.repo.findById(id);    if (!user) throw new NotFoundException('User not found');    return user;  }}

Pitfall thực tế. Quên @Injectable() trên service → Nest vẫn có thể chạy nếu nó chỉ được inject (decorator chủ yếu cần khi class có dependency), nhưng để nhất quán luôn gắn. Pitfall lớn hơn: God Service — một UsersService 2000 dòng làm cả auth, email, billing. Tách theo domain.


5. Dependency Injection (QUAN TRỌNG — khái niệm mới với FE)#

Định nghĩa. DI là pattern: class không tự tạo dependency của nó (new UserRepository()), mà nhận chúng từ bên ngoài (thường qua constructor). "IoC container" (Inversion of Control) là bộ máy của Nest chịu trách nhiệm tạo và cung cấp các instance đó.

Tại sao quan trọng (đọc kỹ nếu từ FE). Ở FE bạn hầu như luôn import { api } from './api' rồi gọi trực tiếp — dependency bị hard-code. Khi test, bạn phải hack module mock. Với DI:

  • Test/mock dễ: trong test bạn inject một fake repo, không đụng DB thật.
  • Đổi implementation không sửa consumer: đổi EmailService từ SendGrid sang SES chỉ cần đổi provider binding, code service dùng nó không đổi.
  • Lifecycle tập trung: container quản lý singleton, tránh tạo trùng kết nối DB.

Cơ chế.

  1. Constructor injection: Nest đọc type của tham số constructor qua reflect-metadata.
  2. Provider token: mỗi provider có một "token" — mặc định chính là class. Container tra token → trả instance. Token cũng có thể là string/symbol (dùng @Inject('TOKEN')).
  3. Scope: mặc định SINGLETON (một instance dùng chung toàn app). Còn REQUEST (mỗi request một instance — chậm hơn, dùng khi cần state theo request như tenant/user hiện tại) và TRANSIENT (mỗi lần inject một instance mới).

Ví dụ code.

typescriptReady
// custom provider token + useClass (đổi implementation dễ)@Module({  providers: [    { provide: 'MAILER', useClass: SendgridMailer }, // đổi sang SesMailer chỉ ở đây  ],})export class MailModule {}@Injectable()export class NotifyService {  constructor(@Inject('MAILER') private mailer: Mailer) {}}// Test — inject mock, không đụng SendGrid thậtconst service = new NotifyService({ send: jest.fn() } as any);

Pitfall thực tế.

  • Circular dependency: A cần B, B cần A → dùng forwardRef(() => B). Tốt hơn là refactor tách phần chung ra module thứ ba.
  • Inject provider REQUEST-scoped vào một singleton sẽ "nâng" cả chuỗi lên request-scoped (bubbling) → tụt performance mà không nhận ra.
  • FE hay dùng new Service() trong constructor theo thói quen — làm vậy phá DI, container không quản lý được, mất test-ability.

6. DTO + validation#

Định nghĩa. DTO (Data Transfer Object) là class mô tả shape của dữ liệu vào/ra. Kết hợp class-validator (decorator validate) + class-transformer (chuyển plain object → instance class) + ValidationPipe để tự động kiểm tra body/query.

Tại sao quan trọng. "Never trust the client." Request là dữ liệu ngoài tầm kiểm soát → phải validate ở boundary. DTO cho bạn một nguồn sự thật vừa là type TS (compile-time) vừa là rule runtime. FE quen zod — đây là tương đương ở Nest (class-based).

Cơ chế. ValidationPipe (bật global) chạy trước handler: dùng class-transformer biến @Body() thành instance của DTO, rồi class-validator chạy các decorator. Fail → tự ném 400 Bad Request kèm chi tiết lỗi.

  • whitelist: true: strip field không khai báo trong DTO (chống mass-assignment).
  • forbidNonWhitelisted: true: có field thừa thì báo lỗi 400 thay vì strip.
  • transform: true: cho phép ép kiểu (vd "42" → 42 theo type param).

Ví dụ code.

typescriptReady
// create-user.dto.tsexport class CreateUserDto {  @IsEmail() email: string;  @IsString() @MinLength(8) password: string;  @IsOptional() @IsInt() @Min(0) age?: number;}// main.ts — bật globalapp.useGlobalPipes(new ValidationPipe({  whitelist: true,  forbidNonWhitelisted: true,  transform: true,}));

Pitfall thực tế.

  • Quên transform: true → @Query('limit') vẫn là string, so sánh số sai âm thầm.
  • Nested object cần @ValidateNested() và @Type(() => ChildDto) (class-transformer), nếu không validation nested bị bỏ qua.
  • Không bật whitelist → client gửi thêm { isAdmin: true } và nếu bạn spread thẳng vào DB → privilege escalation.

7. Pipes#

Định nghĩa. Pipe là class biến đổi hoặc validate input của handler ngay trước khi handler chạy. Hai nhiệm vụ: transform (đổi kiểu/format) và validation (chặn input xấu). ValidationPipe ở mục 6 chính là một pipe.

Tại sao quan trọng. Đẩy việc parse/validate ra khỏi handler → handler chỉ nhận dữ liệu đã sạch, đúng kiểu. Tái dùng logic validate qua nhiều route.

Cơ chế. Pipe implement PipeTransform, có method transform(value, metadata). Return value được truyền vào handler; throw → request bị chặn (thường 400). Áp ở cấp param, handler, controller, hoặc global.

Ví dụ code.

typescriptReady
// built-in ParseUUIDPipe: id là UUID dạng chuỗi (thống nhất với GĐ04/GĐ05)@Get(':id')findOne(@Param('id', ParseUUIDPipe) id: string) { // id chắc chắn là UUID hợp lệ (vẫn là string)  return this.users.findOne(id);}// ParseIntPipe dành cho tham số số thật, vd @Query('limit', ParseIntPipe) limit: number// custom pipe@Injectable()export class TrimPipe implements PipeTransform {  transform(value: any) {    return typeof value === 'string' ? value.trim() : value;  }}

Pitfall thực tế. ParseUUIDPipe ném 400 khi param không phải UUID (/users/abc; đã chạy trên Nest 12) — đúng ý muốn, nhưng nếu bạn muốn 404 thì phải xử lý khác. UUID hợp lệ nhưng không tồn tại mới đi tiếp tới service và ra 404. Custom pipe đừng nhét business logic (query DB) vào — pipe nên thuần transform/validate; truy vấn DB để kiểm tra tồn tại nên ở service/guard.


8. Guards — authorization (RBAC)#

Định nghĩa. Guard quyết định request có được phép vào handler hay không, trả true/false (hoặc Promise/Observable của boolean). Dùng cho authentication (đã login chưa) và authorization (có quyền không).

Tại sao quan trọng. Tách "ai được làm gì" khỏi business logic. Không phải rải if (!user) throw... trong mỗi handler. Guard chạy trước interceptor & pipe → chặn sớm, tiết kiệm.

Cơ chế. Guard implement CanActivate với canActivate(context: ExecutionContext). ExecutionContext cho phép lấy request. Kết hợp custom decorator (@Roles) + Reflector (đọc metadata gắn trên handler) để làm RBAC (Role-Based Access Control).

Ví dụ code.

typescriptReady
// roles.decorator.tsexport const ROLES_KEY = 'roles';export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);// roles.guard.ts@Injectable()export class RolesGuard implements CanActivate {  constructor(private reflector: Reflector) {}  canActivate(ctx: ExecutionContext): boolean {    const required = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [      ctx.getHandler(), ctx.getClass(),    ]);    if (!required) return true;               // route không yêu cầu role    const { user } = ctx.switchToHttp().getRequest();    return required.some((r) => user?.roles?.includes(r));  }}// dùng@UseGuards(AuthGuard('jwt'), RolesGuard)@Roles('admin')@Delete(':id')remove(@Param('id') id: string) { return this.users.remove(id); }

Pitfall thực tế.

  • Thứ tự guard: AuthGuard phải chạy trước RolesGuard (RolesGuard cần req.user do AuthGuard gắn). @UseGuards chạy theo thứ tự khai báo.
  • reflector.get chỉ đọc metadata trên handler; nếu bạn @Roles ở cấp controller, phải dùng getAllAndOverride([handler, class]) để không sót.
  • Guard trả false → Nest ném 403 Forbidden; nếu muốn 401 khi chưa login, để AuthGuard tự ném 401.

Guard toàn cục, @Public() và @CurrentUser#

Gắn @UseGuards từng route dễ quên một route. Cách an toàn hơn: bật guard đăng nhập cho mọi route (APP_GUARD) và đánh dấu ngoại lệ bằng @Public().

typescriptReady
// public.decorator.tsexport const IS_PUBLIC = 'isPublic'export const Public = () => SetMetadata(IS_PUBLIC, true)// jwt-auth.guard.ts@Injectable()export class JwtAuthGuard extends AuthGuard('jwt') {  constructor(private readonly reflector: Reflector) { super() }  canActivate(ctx: ExecutionContext) {    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [      ctx.getHandler(), ctx.getClass(),    ])    return isPublic ? true : super.canActivate(ctx)  }}// current-user.decorator.ts: lấy req.user do strategy gắn, tuỳ chọn một fieldexport const CurrentUser = createParamDecorator(  (field: keyof AuthUser | undefined, ctx: ExecutionContext) => {    const user = ctx.switchToHttp().getRequest<Request & { user: AuthUser }>().user    return field ? user?.[field] : user  },)// app.module.ts: providers chạy theo thứ tự khai báoproviders: [  { provide: APP_GUARD, useClass: ThrottlerGuard },  { provide: APP_GUARD, useClass: JwtAuthGuard },   // 401 nếu thiếu token  { provide: APP_GUARD, useClass: RolesGuard },     // sau đó mới 403]// controller@Public() @Get('health') health() { return { ok: true } }@Get('me') me(@CurrentUser() user: AuthUser, @CurrentUser('id') id: string) { return { user, id } }

Đã chạy (Nest 12.1.2, tsc --strict, supertest): /health ra 200; /me không token 401, có token 200 và @CurrentUser('id') trả u1; DELETE bằng token user 403, token admin 200, không token 401. Khai báo RolesGuard trước JwtAuthGuard: DELETE không token ra 403 thay vì 401, vì RolesGuard chạy khi chưa có req.user. Với @UseGuards(...) và APP_GUARD, thứ tự quyết định status code, nên ghi nó vào test.


9. Interceptors#

Định nghĩa. Interceptor bọc quanh handler: chạy trước và sau khi handler thực thi. Dùng để: transform response, logging, đo thời gian, cache, timeout, map exception. Dựa trên RxJS (Observable).

Tại sao quan trọng. Cross-cutting concern (áp cho mọi route) không nên copy-paste. Ví dụ: bọc mọi response thành { data, timestamp } — làm một lần bằng interceptor global.

Cơ chế. Implement NestInterceptor với intercept(ctx, next). next.handle() trả Observable của kết quả handler. Bạn dùng RxJS operator (map, tap, timeout, catchError) để can thiệp luồng post-handler.

Ví dụ code.

typescriptReady
@Injectable()export class TransformInterceptor implements NestInterceptor {  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {    const now = Date.now();    return next.handle().pipe(      map((data) => ({ data, took: Date.now() - now })), // reshape response      timeout(5000),                                       // hủy nếu > 5s      catchError((e: unknown) => throwError(() =>        // TimeoutError của RxJS -> 408, không để rơi 500        e instanceof TimeoutError ? new RequestTimeoutException() : e)),    );  }}

Pitfall thực tế. Wrap response thành { data: ... } global sẽ phá contract những endpoint đã publish (FE đang đọc field cũ) — thống nhất format sớm hoặc đánh dấu route ngoại lệ bằng metadata (xem dưới). Nest không có decorator dựng sẵn kiểu @SkipInterceptor: tự viết bằng SetMetadata và đọc cờ bằng Reflector. timeout() chỉ hủy Observable phía Nest, không hủy query DB đang chạy → cần cấu hình timeout ở tầng DB nữa. Không có catchError, TimeoutError của RxJS không phải HttpException nên filter trả 500; đã chạy với handler ngủ 300 ms và timeout(50): có catchError ra 408.

typescriptReady
// skip-transform.decorator.tsexport const SKIP_TRANSFORM = 'skipTransform';export const SkipTransform = () => SetMetadata(SKIP_TRANSFORM, true);// transform.interceptor.ts — đọc cờ bằng Reflector (giống RolesGuard ở mục 8)@Injectable()export class TransformInterceptor implements NestInterceptor {  constructor(private reflector: Reflector) {}  intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {    const skip = this.reflector.getAllAndOverride<boolean>(SKIP_TRANSFORM, [      ctx.getHandler(), ctx.getClass(),    ]);    if (skip) return next.handle();            // route ngoại lệ: trả nguyên    return next.handle().pipe(map((data) => ({ data })));  }}@SkipTransform()@Get('health')health() { return { status: 'ok' }; }          // không bị bọc { data }

Đã chạy trên Nest 12.1.2 (interceptor đăng ký bằng APP_INTERCEPTOR): /wrapped trả {"data":{...}}, route gắn @SkipTransform() trả nguyên.


10. Exception filters#

Định nghĩa. Filter bắt exception ném ra từ handler/guard/pipe và biến thành HTTP response chuẩn hoá. Nest có sẵn HttpException và các subclass (NotFoundException, BadRequestException, UnauthorizedException...).

Tại sao quan trọng. Cần một error contract nhất quán để FE parse. Không để lộ stack trace/thông tin DB ra client. Một chỗ tập trung để log + format lỗi.

Cơ chế. Ném HttpException(message, status) → default filter trả JSON { statusCode, message }. Custom filter implement ExceptionFilter + @Catch(...), truy cập response để tự định dạng.

Ví dụ code.

typescriptReady
@Catch()                       // bắt mọi exceptionexport class AllExceptionsFilter implements ExceptionFilter {  private readonly log = new Logger(AllExceptionsFilter.name);  constructor(private readonly adapterHost: HttpAdapterHost) {}   // không phụ thuộc Express/Fastify  catch(err: unknown, host: ArgumentsHost) {    const status = err instanceof HttpException ? err.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;    if (status >= 500) this.log.error(err instanceof Error ? err.stack : String(err));    this.adapterHost.httpAdapter.reply(host.switchToHttp().getResponse(), {      statusCode: status,      message: err instanceof HttpException ? err.getResponse() : 'Internal error',      timestamp: new Date().toISOString(),    }, status);  }}// đăng ký bằng { provide: APP_FILTER, useClass: AllExceptionsFilter } để DI cấp HttpAdapterHost// service ném lỗi nghiệp vụ:throw new ConflictException('Email already exists'); // -> 409

Pitfall thực tế. Bắt hết bằng @Catch() mà quên log lỗi 500 → mất dấu bug production. Đừng trả err.message của lỗi không phải HttpException ra client (có thể lộ path DB, SQL). Với lỗi Prisma/TypeORM (vd unique constraint) nên map sang ConflictException trong service, đừng để rơi xuống 500. Đã chạy (Nest 12.1.2, bản filter dùng HttpAdapterHost): handler ném new Error('select * from users') ra 500, body không chứa chữ select, log server có stack.


11. Auth đầy đủ — JWT access + refresh, hashing#

Định nghĩa. Xác thực (authentication) + phát hành JWT access token (ngắn hạn) và refresh token (dài hạn), có rotation, mật khẩu hash bằng argon2/bcrypt. Dùng Passport (thư viện auth chuẩn Node) qua @nestjs/passport.

Tại sao quan trọng. Đây là "cửa vào" của app — sai sót = rò rỉ tài khoản. Access token ngắn hạn giảm thiệt hại khi bị lộ; refresh token cho UX không phải login lại liên tục; hashing bảo vệ password khi DB bị dump.

Cơ chế.

  • Password hashing: KHÔNG bao giờ lưu plaintext. argon2 (khuyến nghị hiện đại, chống GPU) hoặc bcrypt. Hash có salt tự sinh; verify bằng hàm compare (constant-time).
  • Login: verify password → phát accessToken (~15m) + refreshToken (~7d, ký secret khác).
  • Passport strategy: JwtStrategy extract token từ Authorization: Bearer, verify chữ ký, validate() trả về user → Nest gắn vào req.user.
  • Refresh rotation: mỗi lần dùng refresh token → cấp token mới và vô hiệu token cũ. Phát hiện tái dùng token đã dùng = dấu hiệu bị đánh cắp → thu hồi cả family (mọi refresh token sinh ra từ cùng một lần login). Việc "đánh dấu token cũ đã dùng" phải là một câu lệnh nguyên tử (xem code), không phải "đọc usedAt rồi mới ghi": hai request song song cùng một token đều thấy usedAt = null và cả hai thành công.
  • Mỗi refresh token cần jti (JWT ID, ngẫu nhiên): hai token cùng { sub } ký trong cùng một giây ra chuỗi giống hệt nhau (đã chạy: a === b là true), nên không có jti thì "token mới" không khác "token cũ" và không có khoá để tra session.
  • Lưu refresh token ở đâu: một hàng cho mỗi session trong bảng (vd refresh_session: jti, user_id, family_id, token_hash, used_at) cùng một hàng cho mỗi family (refresh_family: id, revoked_at) giữ cờ thu hồi, không phải một cột hashedRt trên user (cột đó chỉ chứa được một thiết bị: đăng nhập máy thứ hai ghi đè máy đầu). Lưu hash; refresh token là chuỗi ký có entropy cao nên SHA-256 là đủ, slow hash (argon2) dành cho password. Client giữ refresh token trong httpOnly secure cookie (chống XSS đọc), không lưu localStorage.

Ví dụ code.

typescriptReady
// hashingconst hash = await argon2.hash(dto.password);const ok   = await argon2.verify(user.passwordHash, dto.password);// jwt.strategy.ts@Injectable()export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {  constructor(cfg: ConfigService) {    super({      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),      secretOrKey: cfg.get('JWT_ACCESS_SECRET'),    });  }  validate(payload: { sub: string; roles: string[] }) {    return { id: payload.sub, roles: payload.roles }; // -> req.user  }}// auth.module.ts: imports: [JwtModule.register({})] (secret truyền ở từng lần ký/verify)// auth.service.ts — access token + refresh token có jti, mỗi session một hàngconst sha256 = (s: string) => createHash('sha256').update(s).digest('hex');signAccess(userId: string, roles: string[]) {  return this.jwt.signAsync({ sub: userId, roles }, { secret: this.atSecret, expiresIn: '15m' });}private async signRefresh(userId: string) {  const jti = randomUUID();  const token = await this.jwt.signAsync(    { sub: userId },    { secret: this.rtSecret, expiresIn: '7d', jwtid: jti },  );  return { jti, token, tokenHash: sha256(token) };}async startSession(userId: string) {           // gọi khi login: mở family mới  const r = await this.signRefresh(userId);  // create() INSERT refresh_family(familyId) rồi INSERT session đầu tiên, cùng một transaction  await this.sessions.create({ jti: r.jti, userId, familyId: randomUUID(), tokenHash: r.tokenHash });  return r.token;}async refresh(token: string) {  let p: { sub: string; jti: string };  try {    p = await this.jwt.verifyAsync(token, { secret: this.rtSecret, algorithms: ['HS256'] });  } catch { throw new UnauthorizedException(); }  const s = await this.sessions.findByJti(p.jti);  if (!s || s.familyRevoked || s.tokenHash !== sha256(token)) throw new UnauthorizedException();  const next = await this.signRefresh(s.userId);  // claim token cũ + tạo session mới trong MỘT transaction; false = token đã dùng/đã thu hồi  const ok = await this.sessions.rotate(s.jti, {    jti: next.jti, userId: s.userId, familyId: s.familyId, tokenHash: next.tokenHash,  });  if (!ok) {                                    // token cũ bị dùng lại → nghi bị đánh cắp    await this.sessions.revokeFamily(s.familyId);    throw new UnauthorizedException('reuse detected');  }  return { userId: s.userId, refreshToken: next.token };}// sessions.repository — SQL thuần (driver pg). findByJti JOIN refresh_family để lấy `familyRevoked`.// rotate: BEGIN … COMMIT, KHOÁ HÀNG FAMILY trước khi claim://   SELECT 1 FROM refresh_family WHERE id = $family AND revoked_at IS NULL FOR SHARE;   -- 0 hàng → ROLLBACK, false//   UPDATE refresh_session SET used_at = now() WHERE jti = $old AND used_at IS NULL;     -- 0 hàng → ROLLBACK, false//   INSERT INTO refresh_session (jti, user_id, family_id, token_hash) VALUES (…);// revokeFamily: MỘT cờ trên hàng family (không xoá, để còn truy vết)://   UPDATE refresh_family SET revoked_at = now() WHERE id = $family AND revoked_at IS NULL;// startSession: INSERT refresh_family rồi INSERT session đầu tiên.// auth.controller.ts — refresh token đi qua cookie httpOnly, không trả trong JSON@Post('refresh')async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {  const { userId, refreshToken } = await this.auth.refresh(req.cookies?.rt ?? '');  res.cookie('rt', refreshToken, {    httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh', maxAge: 7 * 864e5,  });  const user = await this.users.findOne(userId);   // roles lấy lại từ DB, không nằm trong refresh token  return { accessToken: await this.auth.signAccess(user.id, user.roles) };}

(Cần app.use(cookieParser()) trong main.ts. Logout = revokeFamily của session hiện tại và xoá cookie.)

Vì sao cờ thu hồi nằm ở hàng family. Nếu revokeFamily chỉ UPDATE refresh_session … WHERE family_id = $1, nó chỉ thấy những hàng đã commit lúc câu lệnh bắt đầu. Kẻ tấn công rotate T2 → T3 (transaction còn mở) đúng lúc nạn nhân replay T1: revokeFamily chờ khoá hàng T2, thu hồi T2, nhưng không thấy T3 (commit sau khi câu lệnh bắt đầu), nên T3 vẫn dùng được và bỏ qua phát hiện đánh cắp. Với cờ ở hàng family, rotate giữ FOR SHARE trên hàng đó, revokeFamily (cần khoá ghi) phải chờ rotate commit rồi đặt cờ; mọi token trong family, kể cả T3, chết cùng lúc vì tính hợp lệ được suy ra từ cờ chung.

Đã chạy. Luồng tuần tự login → refresh → refresh → replay token cũ chạy trên Nest 12.1.2 (kho session in-memory). Các phép thử race chạy trên PostgreSQL 17 với pg (bảng thử không có cột hash):

  • Kiểu "đọc usedAt rồi ghi", hai request song song cùng token: [200, 200], family còn 2 token sống, không phát hiện reuse.
  • Cờ thu hồi trên từng hàng session, dựng đúng kịch bản xen kẽ ở trên (giữ transaction của kẻ tấn công mở bằng hai connection): T1, T2 bị thu hồi, T3 used=false revoked=false, và dùng T3 vẫn ra 200. Đây là lỗi.
  • Cờ ở hàng family (mã ở trên), cùng kịch bản xen kẽ: revokeFamily thực sự bị chặn chờ khoá (pg_stat_activity thấy 1 phiên chờ Lock), sau khi kẻ tấn công commit thì family bị thu hồi và dùng T3 ra 401.
  • Cờ ở hàng family, 3 request song song cùng một token, lặp 300 lần: cả 300 lần ra đúng một 200 và hai 401 reuse, không còn token sống nào sau mỗi vòng. Hai 401 về nguyên tắc có thể là "reuse" hoặc "đã thu hồi" (request đến muộn thấy cờ family đã đặt ở bước kiểm trước transaction); các lần đo không gặp trường hợp thứ hai, nhưng đừng viết test khẳng định đúng dạng 401.
  • Tuần tự: refresh 200, replay 401 reuse, thử tiếp 401.

Đánh đổi: cả family bị thu hồi, nên token mới của bên thắng cũng chết. Hai refresh song song hợp lệ (vd app mobile gửi đôi) không phân biệt được với replay. Muốn khoan dung thì thêm cửa sổ ngay-sau-khi-rotate ngắn; đừng bỏ bước UPDATE nguyên tử.

Sơ đồ: rotation và phát hiện reuse
textReady
 login    --> family F, token T1 (used_at = null) refresh(T1): BEGIN   SELECT ... FROM refresh_family WHERE id=F AND revoked_at IS NULL FOR SHARE   UPDATE refresh_session SET used_at=now() WHERE jti=T1 AND used_at IS NULL     -> 1 hàng: INSERT T2; COMMIT; trả T2     -> 0 hàng: ROLLBACK -> revokeFamily(F), 401 "reuse detected" replay T1 (kẻ cắp hoặc nạn nhân): UPDATE ra 0 hàng -> cả family F chết

Mong đợi: refresh 200, refresh lại đúng token cũ 401, và sau đó cả token mới T2 cũng 401 (vì cờ revoked_at ở hàng family). Đã chạy ở vòng kiểm chứng trước (kết quả ở đoạn "Đã chạy" của mục này); sơ đồ chỉ tóm lại luồng.

Pitfall thực tế.

  • Ký access và refresh bằng cùng secret → không phân biệt được token loại nào, mở đường lạm dụng. Dùng 2 secret khác nhau.
  • Lưu refresh token plaintext trong DB → DB leak = mất hết session. Luôn hash.
  • Không rotate refresh → token bị đánh cắp dùng mãi. Rotate + phát hiện reuse.
  • Không gắn jti → không phân biệt được các refresh token cùng user; chỉ một cột hashedRt trên user → mỗi user một thiết bị.
  • Rotation kiểu "đọc usedAt rồi ghi" có race: hai refresh song song cùng token đều thành công. Claim bằng một UPDATE … WHERE used_at IS NULL và kiểm số hàng bị ảnh hưởng.
  • Thu hồi bằng cách UPDATE mọi hàng session của family có race với một rotate đang mở: token mới tạo trong transaction đó không bị thu hồi. Đặt cờ ở một hàng family dùng chung và để rotate khoá hàng đó.
  • Để access token quá dài hạn (vd 30 ngày) → mất token = mất tài khoản lâu; giữ ngắn, dựa vào refresh.
  • Đưa dữ liệu nhạy cảm vào JWT payload — nhớ JWT chỉ ký, không mã hoá, ai cũng decode đọc được payload.

JwtModule theo cấu hình và giới hạn tốc độ đăng nhập#

JwtModule.register({}) ở trên truyền secret từng lần ký. Nếu muốn secret access đọc từ ConfigService một lần, dùng registerAsync; hai secret khác nhau vẫn truyền riêng khi ký/verify refresh. Đăng nhập là đích dò mật khẩu, nên thêm @nestjs/throttler.

typescriptReady
JwtModule.registerAsync({  global: true,                                   // khỏi import JwtModule ở từng module  inject: [ConfigService],  useFactory: (cfg: ConfigService) => ({    secret: cfg.getOrThrow<string>('JWT_ACCESS_SECRET'),    signOptions: { expiresIn: '15m' },  }),}),ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),   // ttl tính bằng mili giây; guard: APP_GUARD (mục 8)// auth.controller.ts: giới hạn chặt hơn cho login@Public() @Throttle({ default: { limit: 5, ttl: 60_000 } }) @Post('login')

Đã chạy (@nestjs/throttler 6.7.1): route limit: 3 trả 200, 200, 200, 429, 429 cho năm request liên tiếp. Theo tài liệu Nest: sau reverse proxy phải bật trust proxy thì IP mới đúng, và nhiều instance cần storage dùng chung (mặc định là bộ nhớ trong từng process); hai điểm này chưa chạy. Rate limit qua Redis xem GĐ09.


12. Config — @nestjs/config + validate env#

Định nghĩa. @nestjs/config load biến môi trường (.env) và cung cấp ConfigService để đọc có type. Validate env lúc khởi động bằng Joi hoặc Zod.

Tại sao quan trọng. Tách config khỏi code (12-factor). Validate sớm → app fail fast ngay lúc boot nếu thiếu DATABASE_URL, thay vì crash lúc 3h sáng khi request đầu tiên chạm biến undefined.

Cơ chế. ConfigModule.forRoot({ isGlobal, validationSchema }) đọc .env, chạy schema validate; sai → throw ngay, app không start. ConfigService.get('KEY') trả value đã validate.

Ví dụ code.

typescriptReady
@Module({  imports: [ConfigModule.forRoot({    isGlobal: true,    validationSchema: Joi.object({      NODE_ENV: Joi.string().valid('development','test','production').required(),      DATABASE_URL: Joi.string().uri().required(),      JWT_ACCESS_SECRET: Joi.string().min(32).required(),    }),  })],})export class AppModule {}// dùngconstructor(private cfg: ConfigService) {}const url = this.cfg.get<string>('DATABASE_URL');

Pitfall thực tế. Không validate env → typo DATABSE_URL cho undefined âm thầm, DB connect fail mơ hồ. Commit .env lên git = lộ secret (đưa vào .gitignore, dùng .env.example). isGlobal: true để khỏi import ConfigModule ở mọi feature module.


13. Database integration + repository pattern#

Định nghĩa. Tích hợp DB qua ORM: Prisma (schema-first, type-safe, DX tốt) hoặc TypeORM (decorator entity, quen với người từ Java/.NET). Repository pattern: một lớp trung gian đóng gói mọi truy vấn cho một entity, service không viết query trực tiếp.

Tại sao quan trọng. Repository cô lập tầng dữ liệu → đổi ORM/DB dễ hơn, test service bằng cách mock repo, và tránh rải câu query khắp codebase. Prisma cho type an toàn end-to-end (kết quả query có type chuẩn).

Cơ chế. Bọc client DB trong một provider (PrismaService extends PrismaClient, connect trong onModuleInit). Repository là @Injectable() inject PrismaService, expose method domain (findById, create...). Service inject repository.

Ví dụ code.

typescriptReady
@Injectable()export class PrismaService extends PrismaClient implements OnModuleInit {  async onModuleInit() { await this.$connect(); }}@Injectable()export class UserRepository {  constructor(private prisma: PrismaService) {}  findById(id: string) { return this.prisma.user.findUnique({ where: { id } }); }  create(data: Prisma.UserCreateInput) { return this.prisma.user.create({ data }); }}

Pitfall thực tế.

  • N+1 query: loop qua danh sách rồi query từng phần tử. Dùng include/join hoặc batch.
  • Quên onModuleInit/pool config → cạn connection pool dưới tải.
  • Prisma 7: PrismaClient bắt buộc nhận driver adapter, nên PrismaService gọi super({ adapter }) trong constructor và import client từ thư mục output đã generate (đã chạy trên Prisma 7.10.0). Xem hộp phiên bản ở GĐ05.
  • Với TypeORM, synchronize: true ở production = tự đổi schema, có thể mất data. Luôn dùng migration ở prod.
  • Đừng leak type ORM (Prisma.User) ra tận controller/response nếu chứa field nhạy cảm (passwordHash) — map sang DTO output hoặc select không chứa field đó. @Exclude() chỉ có tác dụng với instance của class và khi đã bật ClassSerializerInterceptor; Prisma trả object thường nên @Exclude() không làm gì (đã chạy trên Nest 12.1.2: object thường ra passwordHash cả khi bật interceptor; instance class không có interceptor cũng ra passwordHash; chỉ instance class cộng interceptor mới loại field).

14. Swagger auto-doc — @nestjs/swagger#

Định nghĩa. Sinh tài liệu OpenAPI (Swagger UI) tự động từ decorator. @nestjs/swagger đọc DTO, controller, response type để dựng spec + trang UI tương tác.

Tại sao quan trọng. API doc luôn đồng bộ với code (sinh từ chính DTO/controller) → FE và team ngoài tự thử API, giảm hỏi đáp. Đây là "hợp đồng sống" giữa BE và consumer.

Cơ chế. SwaggerModule.setup() quét metadata. Decorator: @ApiTags, @ApiProperty (trên field DTO), @ApiResponse, @ApiBearerAuth (đánh dấu route cần token). Plugin CLI của swagger có thể tự suy field từ type để bớt phải viết @ApiProperty.

Ví dụ code.

typescriptReady
// main.tsconst doc = SwaggerModule.createDocument(app,  new DocumentBuilder().setTitle('Portfolio API').addBearerAuth().build());SwaggerModule.setup('docs', app, doc); // GET /docs// dtoexport class CreateUserDto {  @ApiProperty({ example: 'a@b.com' }) @IsEmail() email: string;}

Pitfall thực tế. Không bật swagger CLI plugin → phải viết @ApiProperty cho mọi field, hay quên → doc thiếu field. Đừng để Swagger UI public ở production nếu API nội bộ (đặt sau auth hoặc chỉ bật ở non-prod).


15. Testing — unit + e2e#

Định nghĩa. Unit test: test một service cô lập, mock dependency. E2E test: chạy app thật (hoặc gần thật) và bắn HTTP request bằng supertest, kiểm tra toàn pipeline (guard, pipe, controller, service).

Tại sao quan trọng. Unit test bắt lỗi logic nhanh, chạy mili-giây, không cần DB. E2E xác nhận các mảnh ghép đúng với nhau (auth guard có chặn không, validation có chạy không). DI của Nest làm cả hai dễ vì có thể override provider.

Cơ chế. Test.createTestingModule({...}) dựng một DI container test; .overrideProvider(X).useValue(mock) thay dependency. Unit: lấy service ra module.get(UsersService). E2E: app = module.createNestApplication() rồi request(app.getHttpServer()).

Ví dụ code.

typescriptReady
// unit — mock repoconst module = await Test.createTestingModule({  providers: [UsersService, { provide: UserRepository, useValue: { findById: jest.fn() } }],}).compile();const service = module.get(UsersService);// e2e — supertestit('GET /users/:id 404 khi không tồn tại', () => {  // UUID hợp lệ nhưng không có trong DB; id sai định dạng (/users/nope) sẽ ra 400 do ParseUUIDPipe  return request(app.getHttpServer())    .get('/users/7c9e6679-7425-40de-944b-e07fc1f90ae7').expect(404);});

Pitfall thực tế. E2E dùng DB thật mà không reset giữa test → test phụ thuộc thứ tự, flaky. Dùng DB test riêng + truncate/transaction rollback mỗi test. Đừng mock quá sâu ở unit test đến mức test chỉ "kiểm tra mock" mà không kiểm tra logic thật.


Dự án 3 (portfolio chính)#

Xây một REST API app thật để làm portfolio — thể hiện toàn bộ GĐ07. Gợi ý: "Mini SaaS / Digital asset store API".

Phạm vi của GĐ07. Phần bắt buộc ở giai đoạn này là auth + RBAC (kể cả OAuth), ValidationPipe, filter, interceptor, Prisma + repository, Swagger và test. Upload, webhook, job và compose bên dưới là đặc tả đầy đủ của dự án; từng phần được làm sâu ở GĐ10 (queue), GĐ12 (file), GĐ15 (Docker). Mục "Done khi" liệt kê cả các phần đó; phần nào chưa kịp làm thì hoãn sang giai đoạn tương ứng và ghi lại, đừng tick khi chưa chạy được.

Feature bắt buộc (chứng minh năng lực BE):

  1. Auth + role đầy đủ

    • Register/login, argon2 hashing, JWT access (15m) + refresh (7d) có rotation.
    • RolesGuard + @Roles('admin'|'user') cho RBAC (vd chỉ admin xoá user, xem tất cả order).
    • Refresh token lưu hash trong DB, logout = revoke.
  2. Upload file lên S3 / Cloudflare R2

    • Endpoint nhận file (multipart), validate type/size bằng pipe, đẩy lên R2/S3, lưu key + trả presigned URL khi cần tải.
    • Không lưu file vào server local (không scale).
    Đáp án
    typescriptReady
    @Post('files')@UseGuards(AuthGuard('jwt'))@UseInterceptors(FileInterceptor('file'))upload(@UploadedFile(new ParseFilePipe({ validators: [  new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }),  new FileTypeValidator({ fileType: /^image\/(png|jpeg)$/ }),] })) file: Express.Multer.File, @CurrentUser() user: AuthUser) {  return this.storage.put(user.id, file)            // key = `${user.id}/${randomUUID()}`; không lưu đĩa local}// storage.service.ts: client S3/R2; SDK v3 mặc định gắn checksum vào presigned PUTconst s3 = new S3Client({ region: 'auto', endpoint: cfg.endpoint,         requestChecksumCalculation: 'WHEN_REQUIRED',      // khuyến nghị (suy luận, chưa gọi S3 thật)  credentials: { accessKeyId: cfg.key, secretAccessKey: cfg.secret } })getUrl(key: string) { return getSignedUrl(s3, new GetObjectCommand({ Bucket: b, Key: key }), { expiresIn: 300 }) }

    @CurrentUser() và AuthUser định nghĩa ở mục 8 (phần "Guard toàn cục"). R2 hỗ trợ presigned GET/PUT, không hỗ trợ presigned POST. Chỉ trả URL cho file thuộc user đang đăng nhập (kiểm key theo user.id, trả 404 nếu không phải của họ).

  3. Payment webhook

    • Tích hợp Stripe/SePay: tạo checkout, nhận webhook báo thanh toán thành công.
    • Verify signature webhook (chống giả mạo), idempotency (webhook có thể gửi lại — không cộng tiền 2 lần).
    Đáp án
    typescriptReady
    @Post('webhooks/stripe')async stripe(@Req() req: RawBodyRequest<Request>, @Headers('stripe-signature') sig: string) {  let event: Stripe.Event  try { event = this.stripe.webhooks.constructEvent(req.rawBody!, sig, this.cfg.webhookSecret) }  catch { throw new BadRequestException('invalid signature') }   // body bị sửa hoặc không phải Stripe  await this.payments.handle(event)  return { received: true }}// payment.service.tsasync handle(event: Stripe.Event) {  if (event.type !== 'checkout.session.completed'    && event.type !== 'checkout.session.async_payment_succeeded') return  const s = event.data.object as Stripe.Checkout.Session  if (s.payment_status !== 'paid') return                         // thanh toán bất đồng bộ: chờ async_payment_succeeded  const orderId = s.metadata?.orderId as string  const paid = await this.prisma.$transaction(async (tx) => {    const n = await tx.$executeRaw`      INSERT INTO processed_event (id) VALUES (${event.id}) ON CONFLICT DO NOTHING`    if (n === 0) return false                                     // đã xử lý: bỏ qua, vẫn trả 200    await tx.$executeRaw`UPDATE "order" SET status = 'paid' WHERE id = ${orderId}::uuid AND status = 'pending'`    return true  })  if (paid) await this.receipts.add('receipt', { orderId, v: 1 }, { jobId: `receipt-${orderId}` })}

    Khoá idempotency (processed_event.id là khoá chính) và việc đổi trạng thái đơn nằm chung một transaction nên không có cửa sổ "đánh dấu đã xử lý nhưng chưa ghi". Enqueue sau commit có thể mất nếu process chết ngay giữa chừng: dùng outbox (GĐ10 mục 4) khi mất biên lai là không chấp nhận được. Mẫu idempotency tổng quát: GĐ10 mục 3.

  4. Background job

    • Queue (BullMQ + Redis): sau khi thanh toán → job gửi email biên nhận, xử lý ảnh, hoặc cleanup.
    • Xử lý retry + dead-letter khi job fail.
    Đáp án
    typescriptReady
    @Processor('receipts', { concurrency: 5 })export class ReceiptProcessor extends WorkerHost {  async process(job: Job<{ orderId: string; v: number }>) {    await this.mailer.sendReceipt(job.data.orderId)   // lỗi tạm: throw để retry; lỗi vĩnh viễn: UnrecoverableError  }  // dead-letter: sau khi hết attempts job nằm lại trong failed set; báo động tại đây  @OnWorkerEvent('failed')  onFailed(job: Job | undefined, err: Error) {    // UnrecoverableError dừng retry ngay lần đầu, lúc đó attemptsMade < attempts    if (job && (err.name === 'UnrecoverableError' || job.attemptsMade >= (job.opts.attempts ?? 1))) {      this.alerts.notify('receipt job hết lượt retry', { id: job.id, err: err.message })    }  }}// đăng ký queue: BullModule.registerQueue({ name: 'receipts', defaultJobOptions: {//   attempts: 5, backoff: { type: 'exponential', delay: 1000, jitter: 0.5 },//   removeOnFail: false } })   // giữ job lỗi (failed set) làm dead-letter để xem lại và retry tay

    Job lỗi vĩnh viễn hoặc hết 5 lần thử không bị mất: nó ở lại trong failed set (với removeOnFail: false; đặt { age, count } nếu muốn giới hạn dung lượng) và listener failed bắn cảnh báo. Có thể dùng queue DLQ riêng (đẩy job vào queue receipts-dead từ listener) khi cần xử lý lại có kiểm soát. Chi tiết ở GĐ10. Lớp ReceiptProcessor chưa chạy; điều kiện cảnh báo đã chạy trên BullMQ 6.3.11 với Redis tạm (attempts: 3): job ném UnrecoverableError thất bại một lần với attemptsMade=1, điều kiện cũ ra false (không cảnh báo), điều kiện mới ra true; job ném Error thường chỉ ra true ở lần thứ 3. Cả hai job đều nằm trong failed set.

    Redis dành cho queue phải đặt maxmemory-policy noeviction; cache dùng instance Redis riêng (GĐ09, GĐ10).

Kèm theo (chất lượng production):

  • ValidationPipe global (whitelist), exception filter chuẩn hoá lỗi, interceptor logging + transform response.
  • @nestjs/config + Joi/Zod validate env; secrets qua env, không hardcode.
  • Prisma + repository pattern + migration (không synchronize prod).
  • Swagger /docs có addBearerAuth.
  • Unit test cho service auth/payment + e2e cho luồng login → tạo order → webhook.
  • Dockerfile + docker-compose (app + Postgres + Redis) để chạy 1 lệnh.

Kiến trúc module gợi ý:

textReady
AppModule├── ConfigModule (global)      ├── PrismaModule (global)├── AuthModule (JwtStrategy, guards, refresh rotation)├── UsersModule                ├── UploadModule (S3/R2)├── PaymentModule (checkout + webhook + idempotency)└── JobsModule (BullMQ processors)
Khung và mã dùng chung

Đây là khung và các đoạn then chốt, không phải dự án hoàn chỉnh. Code tham chiếu, chưa chạy (theo phiên bản trong hộp "Kiểm chứng ngày 2026-10-05" ở đầu bài: Nest 12, Prisma 7.10, BullMQ 6, Node 24). Làm theo thứ tự: khung + config + health, rồi auth, rồi users/Prisma, rồi upload, payment, jobs, cuối cùng Docker và test.

Cấu trúc thư mục.

textReady
src/  main.ts                ValidationPipe, cookie-parser, Swagger, rawBody  app.module.ts          ConfigModule (validate env), APP_INTERCEPTOR/FILTER  config/env.schema.ts   Joi/Zod: NODE_ENV, APP_ENV, DATABASE_URL, secrets  common/                all-exceptions.filter, transform.interceptor  prisma/                PrismaModule (global), PrismaService (adapter, mục 13)  auth/                  auth.service/controller, jwt.strategy, jwt-auth.guard,                         roles.guard/decorator, sessions.repository, dto/  users/                 users.controller/service, user.repository, dto/  upload/                upload.controller, storage.service (S3/R2 presigned)  payment/               payment.controller (webhook), payment.service, SQL  jobs/                  receipt.processor (BullMQ), jobs.module  health/                /health/live (không chạm DB), /health/ready (DB, Redis)prisma/  schema.prisma, migrations/     prisma.config.ts ở gốc repotest/    auth.e2e-spec.ts, payment.e2e-spec.tsDockerfile, docker-compose.yml, .env.example (không commit .env)

main.ts: các cờ then chốt.

typescriptReady
const app = await NestFactory.create(AppModule, { rawBody: true }) // webhook cần body thô để verify chữ kýapp.use(cookieParser())app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))app.enableShutdownHooks()                                          // SIGTERM: drain, đóng Prisma/Redis (GĐ09 mục 18)if (process.env.APP_ENV !== 'production') {                        // Swagger không public ở production  SwaggerModule.setup('docs', app,    SwaggerModule.createDocument(app, new DocumentBuilder().setTitle('Portfolio API').addBearerAuth().build()))}await app.listen(Number(process.env.PORT ?? 3000))

docker-compose.yml (rút gọn).

textReady
services:  app:    build: .    env_file: .env    ports: ["3000:3000"]    depends_on:      db:    { condition: service_healthy }      redis: { condition: service_started }  db:    image: postgres:18    environment: { POSTGRES_PASSWORD: dev-only, POSTGRES_DB: app }    healthcheck: { test: ["CMD-SHELL", "pg_isready -U postgres"], interval: 3s, retries: 20 }  redis:    image: redis:8    command: ["redis-server", "--maxmemory-policy", "noeviction"]

Lệnh nghiệm thu và kết quả mong đợi (suy ra từ code ở trên; chạy sau docker compose up -d):

LệnhMong đợi
curl -i localhost:3000/health/live200, không phụ thuộc DB
curl -i -X POST localhost:3000/auth/register -H 'content-type: application/json' -d '{"email":"a","password":"x"}'400 có message nêu email/password
curl -i localhost:3000/users/me (không token)401
curl -i -X DELETE localhost:3000/users/<uuid> -H "authorization: Bearer $USER_TOKEN"403 (token của user thường)
curl -i -X POST localhost:3000/webhooks/stripe -d '{}' -H 'stripe-signature: bad'400 invalid signature
Gửi cùng một event hợp lệ hai lần (Stripe CLI stripe trigger hoặc ký tay bằng secret test)hai lần 200; SELECT count(*) FROM processed_event WHERE id = '<event id>' ra 1; đơn chỉ chuyển paid một lần
pnpm test / pnpm test:e2exanh; e2e có login, tạo order, webhook
docker compose down rồi docker compose upapp tự kết nối lại DB/Redis, /health/ready về 200

Lỗi hay gặp.

  • Không bật rawBody: true hoặc để express.json() parse trước: constructEvent luôn báo sai chữ ký.
  • Verify chữ ký xong nhưng idempotency ghi sau tác dụng phụ ở transaction khác: webhook gửi lại đúng lúc crash sẽ cộng hai lần.
  • Trả 500 cho event đã xử lý: Stripe retry mãi. Trả 200 cho bản trùng.
  • Ký access và refresh cùng secret; hoặc lưu refresh token plaintext (mục 11).
  • Dùng .env trong image hoặc commit .env: chỉ commit .env.example.

Done khi#

  • Hiểu & giải thích được DI/IoC: vì sao inject qua constructor giúp test/mock, phân biệt singleton vs request scope, xử lý được circular dependency.

    Đáp án

    Class nhận dependency qua constructor thay vì new, nên test thay bằng fake ({ provide: Repo, useValue: mock }) mà không đụng DB; đổi implementation chỉ đổi binding. Mặc định singleton; REQUEST scope tạo instance mỗi request (chậm hơn và lan lên mọi provider phụ thuộc nó); TRANSIENT mỗi lần inject một bản. Vòng phụ thuộc: ưu tiên tách phần chung sang module thứ ba, tạm thời forwardRef. Sai thường gặp: new Service() trong constructor. Xem mục 5.

  • Dựng được app Nest có ≥4 feature module, import/export provider đúng, không lỗi "can't resolve dependencies".

    Đáp án

    Tự kiểm: AppModule import AuthModule, UsersModule, UploadModule, PaymentModule, JobsModule; provider dùng chéo phải ở exports của module chủ và imports của module dùng. App khởi động không in Nest can't resolve dependencies. Sai thường gặp: khai báo lại provider ở module khác (tạo hai instance). Xem mục 2.

  • ValidationPipe global chạy: request sai → 400 có message rõ; whitelist strip field lạ.

    Đáp án

    Tự kiểm: curl -X POST /auth/register -H 'content-type: application/json' -d '{"email":"a","password":"x","isAdmin":true}' mong đợi 400 với message cho email và password; với forbidNonWhitelisted: true field lạ cũng ra 400, nếu chỉ whitelist: true thì field lạ bị strip im lặng. Xem mục 6.

  • RolesGuard + @Roles chặn đúng: user thường bị 403, chưa login 401; guard order đúng (Auth trước Roles).

    Đáp án

    Chưa đăng nhập: AuthGuard('jwt') ném 401. Đã đăng nhập thiếu role: RolesGuard trả false nên Nest ném 403. Thứ tự @UseGuards(AuthGuard('jwt'), RolesGuard) đúng vì RolesGuard cần req.user. Tự kiểm: ba curl (không token, token user, token admin) mong đợi 401, 403, 2xx. Xem mục 8.

  • Auth hoàn chỉnh: argon2 hash, access + refresh 2 secret khác nhau, refresh rotation + lưu hash, logout revoke được.

    Đáp án

    Argon2 hash mật khẩu; access ký bằng JWT_ACCESS_SECRET, refresh bằng JWT_REFRESH_SECRET khác nhau; mỗi refresh token có jti, lưu hash SHA-256 một hàng/session cùng cờ thu hồi ở hàng family; rotation claim token cũ bằng một UPDATE ... WHERE used_at IS NULL; logout gọi revokeFamily và xoá cookie. Tự kiểm: refresh hai lần song song cùng token thì đúng một lần thành công. Sai thường gặp: cột hashedRt trên user, hoặc "đọc usedAt rồi ghi". Xem mục 11 (sơ đồ rotation ngay trong mục).

  • Interceptor transform/log response; exception filter chuẩn hoá lỗi + không lộ stack/SQL ra client.

    Đáp án

    Interceptor bọc { data } và log thời gian, route ngoại lệ dùng @SkipTransform() (không có @SkipInterceptor). Filter @Catch() luôn log lỗi 500 phía server nhưng chỉ trả { statusCode, message: 'Internal error' }. Tự kiểm: ném new Error('select * from users') trong handler, response không chứa chữ select, log server có stack. Xem mục 9, 10.

  • Config validate env lúc boot (thiếu biến → fail fast); không commit .env.

    Đáp án

    ConfigModule.forRoot({ validationSchema }) ném khi boot nếu thiếu DATABASE_URL. Tự kiểm: unset DATABASE_URL; node dist/main.js phải thoát ngay với lỗi nêu tên biến; git check-ignore .env in ra .env. Xem mục 12.

  • Prisma/TypeORM qua repository pattern, có migration, không N+1 rõ ràng, không leak passwordHash ra response.

    Đáp án

    Service chỉ gọi UserRepository, không prisma.user.* trực tiếp. Có thư mục prisma/migrations và prod dùng prisma migrate deploy (không synchronize). Tránh N+1 bằng include/select trong một query; không trả passwordHash: map sang DTO output hoặc dùng select không chứa nó. Tự kiểm: curl /users/<id> không có khoá passwordHash. Xem mục 13.

  • Upload S3/R2 hoạt động (presigned URL), payment webhook verify signature + idempotent, background job chạy + retry được.

    Đáp án

    Upload: validate size/type bằng ParseFilePipe, lưu key theo user, trả presigned URL ngắn hạn. Webhook: constructEvent trên body thô, bản trùng bị chặn bằng processed_event trong cùng transaction với việc đổi trạng thái đơn. Job: BullMQ attempts + backoff, lỗi vĩnh viễn dùng UnrecoverableError (xem GĐ10). Tự kiểm: gửi một event hai lần chỉ đổi đơn một lần; làm job ném lỗi tạm thì thấy retry. Xem khung Dự án 3.

  • Swagger /docs mô tả đủ endpoint + bearer auth.

    Đáp án

    DocumentBuilder().addBearerAuth() + @ApiBearerAuth() trên route cần token + @ApiProperty hoặc CLI plugin cho DTO. Tự kiểm: mở /docs, mọi route có trong danh sách, nút Authorize hoạt động, route công khai không có ổ khoá. Xem mục 14.

  • Có unit test service (mock provider) + e2e (supertest) cho ít nhất luồng auth và một luồng nghiệp vụ; test xanh.

    Đáp án

    Unit: Test.createTestingModule với overrideProvider/useValue mock repo, kiểm logic service. E2E: supertest trên app.getHttpServer() cho login, tạo order, webhook; DB test riêng, reset giữa các test. Nest 12 mặc định Vitest nên dùng vi.fn(). Tự kiểm: chạy lệnh test hai lần liên tiếp đều xanh (không phụ thuộc thứ tự). Xem mục 15.

  • Chạy được bằng docker-compose up (app + Postgres + Redis).

    Đáp án

    Ba service app, db (Postgres), redis (--maxmemory-policy noeviction cho queue); depends_on với healthcheck của db. Tự kiểm: docker compose up -d rồi curl localhost:3000/health/ready mong đợi 200. Khung compose ở phần Dự án 3.

  • Mọi route mặc định cần đăng nhập (guard toàn cục), chỉ route có @Public() mở; thứ tự guard khớp status code.

    Đáp án

    Đăng ký ThrottlerGuard, JwtAuthGuard, RolesGuard bằng APP_GUARD theo đúng thứ tự đó; JwtAuthGuard đọc IS_PUBLIC bằng getAllAndOverride([handler, class]). Tự kiểm bằng supertest: route không đánh dấu và không token ra 401, /health có @Public() ra 200, DELETE bằng token user ra 403, bằng token admin ra 2xx. Đảo RolesGuard lên trước JwtAuthGuard thì không token ra 403 (đã đo): đó là dấu hiệu sai thứ tự. Xem mục 8.

  • Cảnh báo dead-letter bắn cả với job ném UnrecoverableError ở lần thử đầu.

    Đáp án

    Điều kiện err.name === 'UnrecoverableError' || job.attemptsMade >= (job.opts.attempts ?? 1) trong listener failed. Tự kiểm: tạo hai job với attempts: 3, một cái ném UnrecoverableError, một cái ném Error thường; job đầu phải cảnh báo ngay với attemptsMade=1, job sau chỉ cảnh báo ở lần thứ 3, và cả hai còn trong failed set (removeOnFail: false). Sai thường gặp: chỉ so attemptsMade với attempts nên lỗi vĩnh viễn không bao giờ báo động. Xem khung Dự án 3, phần Background job.

Nguyên tắc xuyên suốt để nhớ: Controller mỏng → Service chứa logic → Repository chạm DB. Validate ở boundary. Mọi dependency đều inject, không new. Không tin client. Fail fast ở boot.


🔑 Bổ sung — OAuth 2.0 & Social Login (đăng nhập Google/GitHub)#

Bổ sung theo mục tiêu AI SaaS. JWT/Passport ở trên lo xác thực nội bộ; phần này lo đăng nhập bằng nhà cung cấp thứ 3 — gần như bắt buộc cho SaaS.

OAuth 2.0 là gì#

  • Định nghĩa: giao thức uỷ quyền — user cho phép app bạn truy cập thông tin của họ ở provider (Google/GitHub) mà KHÔNG đưa mật khẩu cho bạn.
  • Tại sao quan trọng: SaaS cần "Sign in with Google" để giảm ma sát đăng ký; bạn không phải tự lưu/bảo vệ mật khẩu.
  • Phân biệt: OAuth 2.0 = authorization (cấp quyền); OpenID Connect (OIDC) = lớp authentication xây trên OAuth, trả về id_token (JWT) chứa danh tính. Social login thực chất dùng OIDC.

Authorization Code Flow (flow chuẩn cho web server)#

Cơ chế từng bước:

  1. FE bấm "Login with Google" → redirect tới accounts.google.com kèm client_id, redirect_uri, scope, state.
  2. User đồng ý → Google redirect về redirect_uri?code=...&state=....
  3. Backend đổi code + client_secret lấy access_token (Google có thêm id_token; GitHub không có id_token). Gọi server-to-server, secret không lộ ra FE.
  4. Backend lấy danh tính: Google từ id_token nhận trực tiếp ở bước 3 hoặc từ userinfo, GitHub bằng GET /user và GET /user/emails. Đọc sub (khoá danh tính) và email_verified → tìm hoặc tạo user trong DB → phát JWT của hệ thống bạn (như GĐ trên).
  5. Từ đây user dùng JWT nội bộ; không cần giữ token Google.
typescriptReady
// NestJS + passport-google-oauth20 (rút gọn)@Injectable()export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {  constructor() {    super({      clientID: process.env.GOOGLE_CLIENT_ID,      clientSecret: process.env.GOOGLE_CLIENT_SECRET,      callbackURL: '/auth/google/callback',      scope: ['email', 'profile'],    });  }  async validate(_at: string, _rt: string, profile: Profile) {    // profile.emails[0].value -> upsert user -> return user    return { email: profile.emails[0].value, provider: 'google' };  }}

Đọc mã nguồn passport-google-oauth20 2.0.0 (chưa chạy với Google thật): gói này không verify id_token; nó đổi code rồi gọi userinfo https://www.googleapis.com/oauth2/v3/userinfo và đặt profile.id = sub, profile.emails[0].verified = email_verified. state: true và pkce: true của passport-oauth2 1.8.0 lưu giá trị trong req.session, nên cần express-session; API stateless thường tự viết hai route như bên dưới.

Sơ đồ: Authorization Code Flow (Google, qua backend)
textReady
 Browser            Backend (Nest)                 Google   | GET /auth/google  |                             |   |------------------>| sinh state, lưu cookie      |   |<-- 302 accounts.google.com?client_id&redirect_uri&scope&state   |------------------------------------------------>| user đăng nhập + đồng ý   |<-- 302 redirect_uri?code=...&state=...          |   | GET /auth/google/callback?code&state            |   |------------------>| so state với cookie (sai -> 400)   |                   |-- code + client_secret ---->| (server-to-server)   |                   |<-- id_token + access_token -|   |                   | lấy danh tính (sub), email_verified   |                   | upsert user -> phát JWT nội bộ (GĐ này, mục 11)   |<-- JWT/cookie của hệ thống bạn

Code tham chiếu, chưa chạy. Mong đợi: client_secret chỉ đi trên đoạn Backend-Google; trình duyệt chỉ thấy code dùng một lần. Callback thiếu hoặc sai state phải bị từ chối trước khi đổi code.

Hai route và bàn giao cho SPA#

Mỗi provider chỉ cần hai route công khai (@Public(), mục 8): bắt đầu và callback. Phiên bản tự viết, stateless:

typescriptReady
@Public()@Controller('auth')export class OAuthController {  @Get(':provider')                                   // 1. bắt đầu  start(@Param('provider') name: string, @Res() res: Response) {    const state = b64url(randomBytes(32))    const verifier = b64url(randomBytes(32))          // PKCE: giữ ở server/cookie    const challenge = b64url(createHash('sha256').update(verifier).digest())    res.cookie('oauth_tx', JSON.stringify({ state, verifier }), {      httpOnly: true, secure: true, sameSite: 'lax', path: '/auth', maxAge: 10 * 60_000,    })    res.redirect(this.provider(name).authorizeUrl({ state, challenge, redirectUri: this.redirectUri(name) }))  }  @Get(':provider/callback')                          // 2. callback  async callback(@Param('provider') name: string, @Query('code') code: string | undefined,    @Query('state') state: string | undefined, @Req() req: Request, @Res() res: Response) {    let tx: { state: string; verifier: string } | undefined    try { tx = JSON.parse(req.cookies?.oauth_tx ?? '') } catch { /* thiếu cookie */ }    res.clearCookie('oauth_tx', { path: '/auth' })    // dùng một lần    const a = Buffer.from(state ?? ''), b = Buffer.from(tx?.state ?? '')    if (!code || !tx || a.length !== b.length || !timingSafeEqual(a, b)) {      throw new BadRequestException('state không hợp lệ')   // trước khi đổi code    }    const id = await this.provider(name).fetchIdentity({ code, verifier: tx.verifier, redirectUri: this.redirectUri(name) })    const user = await this.oauth.signIn(name, id)    // gộp hoặc tạo user, xem mục dưới    const refresh = await this.auth.startSession(user.id)   // mục 11    res.cookie('rt', refresh, { httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh', maxAge: 7 * 864e5 })    res.redirect(`${this.cfg.getOrThrow<string>('WEB_URL')}/auth/done`)  }}

Bàn giao cho SPA: callback đặt refresh token vào cookie httpOnly (đúng cookie rt của mục 11) rồi chuyển hướng về SPA; SPA gọi POST /auth/refresh để lấy access token. Không đưa JWT vào URL. Cookie rt chỉ đi kèm request khi SPA và API cùng site (cùng domain đăng ký) hoặc SPA gọi API qua proxy cùng origin.

fetchIdentity mỗi provider khác nhau: GitHub đổi code ở https://github.com/login/oauth/access_token rồi gọi GET https://api.github.com/user lấy id (số, đổi sang chuỗi làm sub) và GET /user/emails lấy email primary cùng cờ verified; Google đổi code ở https://oauth2.googleapis.com/token rồi gọi userinfo lấy sub, email, email_verified. GitHub nhận code_challenge với S256 (không có plain); phía Google chưa xác minh (xem hộp kiểm chứng đầu bài).

Đã chạy (Nest 12.1.2, tsc --strict, supertest, provider giả, kho user trong bộ nhớ, không gọi Google/GitHub thật): GET /auth/google trả 302 tới host provider với state 43 ký tự, có code_challenge và cookie SameSite=Lax; callback với state sai hoặc thiếu cookie trả 400 và provider giả không bị gọi (0 lần); callback đúng trả 302 về WEB_URL/auth/done, đặt cookie rt, không có chuỗi JWT trong header. Hai hàm fetchIdentity thật chỉ biên dịch bằng tsc, chưa chạy.

Danh tính và gộp tài khoản#

Lưu danh tính ở bảng riêng, khoá là (provider, sub):

textReady
CREATE TABLE user_identity (  provider text NOT NULL,  provider_sub text NOT NULL,  user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,  PRIMARY KEY (provider, provider_sub));
typescriptReady
async signIn(provider: string, id: ProviderIdentity): Promise<UserRow> {  const linked = await this.repo.findUserByIdentity(provider, id.sub)  if (linked) return linked                           // đã liên kết: bỏ qua email  if (!id.email || !id.emailVerified) throw new UnauthorizedException('email chưa được provider xác minh')  const existing = await this.repo.findUserByEmail(id.email)  if (!existing) return this.repo.createUserWithIdentity(provider, id.sub, id.email)  if (!existing.emailVerified) throw new UnauthorizedException('tài khoản cùng email chưa xác minh email')  await this.repo.linkIdentity(existing.id, provider, id.sub)   // gộp  return existing}

Quy tắc: tìm theo (provider, sub) trước; chỉ khi chưa có mới xét email, và chỉ gộp khi email do provider xác minh và tài khoản sẵn có cũng đã xác minh email. Nếu không, kẻ tấn công đăng ký tài khoản mật khẩu bằng email của nạn nhân (chưa xác minh) rồi chờ nạn nhân đăng nhập Google: hai bên bị gộp và kẻ tấn công giữ mật khẩu của tài khoản đó.

Đã chạy với kho giả: Google g1 (email đã xác minh, user cục bộ đã xác minh) gộp vào user cũ; GitHub gh9 cùng email gộp vào cùng user (2 dòng user_identity, vẫn 1 user); email emailVerified=false ra 401, không tạo gì; email trùng tài khoản cục bộ chưa xác minh ra 401; cùng sub Google với email đã đổi vẫn vào đúng user, không tạo user mới.

Điểm phải nắm#

  • state chống CSRF: sinh random, lưu session/cookie, so lại ở callback. Bỏ qua = lỗ hổng.
  • PKCE: bắt buộc cho SPA/mobile (public client) — chống đánh cắp code. RFC 9700 (mục 2.1.1) cũng khuyến nghị cho client có secret, kể cả web app, và PKCE còn chống CSRF ở đường callback (mục 4.7.1). Bật cả state lẫn PKCE: state là cách chắc chắn khi provider chưa hỗ trợ PKCE.
  • Danh tính là (provider, sub), không phải email. sub không đổi suốt đời tài khoản còn email thì đổi được (OIDC Core mục 2; Google khuyên rõ không dùng email làm khoá). Email chỉ để gộp tài khoản, xem mục "Danh tính và gộp tài khoản" bên dưới.
  • Chỉ tin email đã email_verified từ provider (GitHub: mục primary có verified: true trong /user/emails, cần scope user:email).
  • Đừng lưu access_token provider nếu không cần gọi API của họ tiếp; chỉ cần danh tính là đủ.

Pitfall thực tế#

  • Redirect URI phải khớp tuyệt đối với cấu hình trên console provider (kể cả trailing slash) → lỗi redirect_uri_mismatch.
  • Nhầm id_token (danh tính) với access_token (gọi API).
  • id_token lấy từ nơi khác ngoài token endpoint (client gửi lên, bên thứ ba) mà không verify chữ ký → giả mạo. Nhận trực tiếp từ token endpoint qua HTTPS cùng client_secret thì Google nói không bắt buộc verify chữ ký.
  • Cookie state đặt sameSite: 'strict': callback là điều hướng từ site provider nên cookie không được gửi, theo quy tắc SameSite trình duyệt có thể không gửi cookie này kèm điều hướng từ site khác (chưa đo), nên callback thấy thiếu cookie và ra 400. Dùng lax.
  • Đặt JWT hoặc refresh token vào query của URL chuyển hướng về SPA: lọt vào lịch sử và log.

Done bổ sung khi#

  • Dự án 3 có "Login with Google/GitHub" → tạo user → phát JWT nội bộ, có state, xử lý account linking cơ bản.

    Đáp án

    Code tham chiếu, chưa chạy. Cách tự kiểm: (1) bấm "Login with Google" rồi xem URL redirect có đủ client_id, redirect_uri, scope, state; (2) sửa state trên URL callback thì backend phải trả 400/401 và không tạo user; (3) đăng nhập bằng Google rồi GitHub với cùng email đã verify thì bảng users vẫn một hàng, bảng liên kết có hai provider; (4) email_verified = false thì không gộp tài khoản, không phát JWT. Bước đổi code lấy token luôn nằm ở backend; JWT trả về là của hệ thống bạn (mục 11), không lưu access_token của provider nếu không cần gọi API của họ.

    typescriptReady
    // Trong validate() của strategy: chỉ tin email đã xác minhconst email = profile.emails?.find((e) => e.verified)?.valueif (!email) throw new UnauthorizedException('email chưa được provider xác minh')

    Cờ verified của passport-google-oauth20 phụ thuộc phiên bản gói: kiểm tra trên profile thật khi làm bài.

  • Callback chặn state sai trước khi đổi code, và danh tính lưu theo (provider, sub).

    Đáp án

    Tự kiểm bằng provider giả (hoặc bản thật ở local): GET /auth/google/callback?code=c&state=WRONG kèm cookie oauth_tx hợp lệ phải ra 400 và provider không bị gọi; không có cookie cũng 400. Sau một lần đăng nhập đúng, bảng user_identity có đúng một dòng (google, <sub>); đổi email của tài khoản Google rồi đăng nhập lại vẫn vào cùng user, không tạo user mới (khoá là sub, không phải email). Cookie oauth_tx dùng sameSite: 'lax' và bị xoá ở callback. Xem mục "Hai route và bàn giao cho SPA".

  • Gộp tài khoản không mở đường chiếm tài khoản: chỉ gộp khi cả hai phía đã xác minh email, và JWT không đi qua URL.

    Đáp án

    Tình huống kiểm: có user mật khẩu b@x.com chưa xác minh email; đăng nhập GitHub với b@x.com đã xác minh phải ra 401, không thêm dòng user_identity (đã đo với kho giả: 401, số dòng giữ nguyên). Với user đã xác minh email thì gộp thành công và vẫn một hàng users. Callback chỉ đặt cookie rt rồi chuyển hướng về WEB_URL/auth/done; kiểm Location không chứa token= hay chuỗi bắt đầu eyJ. Xem mục "Danh tính và gộp tài khoản".