@codecanva/nest-auth 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,682 @@
1
+ # `@codecanva/nest-auth` Installation Guide
2
+
3
+ End-to-end guide for wiring `@codecanva/nest-auth` into a NestJS + Mongoose project. Includes every file we created plus the dependency, env, and bootstrap changes.
4
+
5
+ > **Shortcut:** `npx @codecanva/nest-auth init` scaffolds everything below
6
+ > (files, env vars, AppModule + `main.ts` wiring, dependencies) automatically.
7
+ > Use `--dry-run` to preview, `--store memory` for a no-DB trial. This document
8
+ > is the manual reference for what that command generates and why.
9
+
10
+ ---
11
+
12
+ ## 1. Install dependencies
13
+
14
+ `@codecanva/nest-auth` requires `class-validator@^0.14.x`. If you have `0.15.x` installed, downgrade it here.
15
+
16
+ ```bash
17
+ npm install \
18
+ @codecanva/nest-auth \
19
+ @nestjs/jwt @nestjs/passport \
20
+ passport passport-jwt \
21
+ bcrypt \
22
+ class-validator@^0.14.1
23
+
24
+ npm install -D @types/passport-jwt @types/bcrypt
25
+ ```
26
+
27
+ ---
28
+
29
+ ## 2. Environment variables
30
+
31
+ Add these to your `.env` (use strong random values in real environments):
32
+
33
+ ```dotenv
34
+ JWT_ACCESS_SECRET=change-me-access-secret-min-32-chars-long-please
35
+ JWT_REFRESH_SECRET=change-me-refresh-secret-min-32-chars-long-different
36
+ TOKEN_HASH_PEPPER=change-me-pepper
37
+ ```
38
+
39
+ Update `src/config/env.validation.ts` so the app fails fast if they're missing:
40
+
41
+ ```ts
42
+ // src/config/env.validation.ts
43
+ const REQUIRED_KEYS = [
44
+ 'MONGODB_URI',
45
+ 'JWT_ACCESS_SECRET',
46
+ 'JWT_REFRESH_SECRET',
47
+ ] as const;
48
+
49
+ export function validateEnv(
50
+ config: Record<string, unknown>,
51
+ ): Record<string, unknown> {
52
+ const missing = REQUIRED_KEYS.filter((key) => !config[key]);
53
+ if (missing.length > 0) {
54
+ throw new Error(
55
+ `Missing required environment variables: ${missing.join(', ')}. ` +
56
+ `Check your .env file.`,
57
+ );
58
+ }
59
+
60
+ const uri = String(config.MONGODB_URI);
61
+ if (!/^mongodb(\+srv)?:\/\//.test(uri)) {
62
+ throw new Error(
63
+ `MONGODB_URI must start with "mongodb://" or "mongodb+srv://" (got: "${uri}")`,
64
+ );
65
+ }
66
+
67
+ return config;
68
+ }
69
+ ```
70
+
71
+ ---
72
+
73
+ ## 3. User module
74
+
75
+ ### 3.1 User schema
76
+
77
+ ```ts
78
+ // src/user/schemas/user.schema.ts
79
+ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
80
+ import { HydratedDocument } from 'mongoose';
81
+
82
+ export type UserDocument = HydratedDocument<User>;
83
+
84
+ @Schema({ timestamps: true })
85
+ export class User {
86
+ @Prop({ required: true, unique: true, lowercase: true, trim: true, index: true })
87
+ email: string;
88
+
89
+ @Prop({ required: true })
90
+ passwordHash: string;
91
+
92
+ @Prop({ type: [String], default: [] })
93
+ roles: string[];
94
+ }
95
+
96
+ export const UserSchema = SchemaFactory.createForClass(User);
97
+ ```
98
+
99
+ ### 3.2 DTO
100
+
101
+ ```ts
102
+ // src/user/dto/create-user.dto.ts
103
+ import { IsEmail, IsString, MinLength } from 'class-validator';
104
+
105
+ export class CreateUserDto {
106
+ @IsEmail()
107
+ email: string;
108
+
109
+ @IsString()
110
+ @MinLength(8)
111
+ password: string;
112
+ }
113
+ ```
114
+
115
+ `src/user/dto/update-user.dto.ts` stays as-is (PartialType).
116
+
117
+ ### 3.3 Service
118
+
119
+ ```ts
120
+ // src/user/user.service.ts
121
+ import {
122
+ ConflictException,
123
+ Injectable,
124
+ NotFoundException,
125
+ } from '@nestjs/common';
126
+ import { InjectModel } from '@nestjs/mongoose';
127
+ import { Model } from 'mongoose';
128
+ import * as bcrypt from 'bcrypt';
129
+ import { CreateUserDto } from './dto/create-user.dto';
130
+ import { UpdateUserDto } from './dto/update-user.dto';
131
+ import { User, UserDocument } from './schemas/user.schema';
132
+
133
+ const BCRYPT_ROUNDS = 12;
134
+
135
+ @Injectable()
136
+ export class UserService {
137
+ constructor(
138
+ @InjectModel(User.name) private readonly userModel: Model<UserDocument>,
139
+ ) {}
140
+
141
+ async create(dto: CreateUserDto): Promise<UserDocument> {
142
+ const existing = await this.userModel.findOne({ email: dto.email }).lean();
143
+ if (existing) {
144
+ throw new ConflictException('Email already registered');
145
+ }
146
+ const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
147
+ return this.userModel.create({ email: dto.email, passwordHash });
148
+ }
149
+
150
+ findAll(): Promise<UserDocument[]> {
151
+ return this.userModel.find().exec();
152
+ }
153
+
154
+ findByEmail(email: string): Promise<UserDocument | null> {
155
+ return this.userModel.findOne({ email: email.toLowerCase() }).exec();
156
+ }
157
+
158
+ findById(id: string): Promise<UserDocument | null> {
159
+ return this.userModel.findById(id).exec();
160
+ }
161
+
162
+ async update(id: string, dto: UpdateUserDto): Promise<UserDocument> {
163
+ const updated = await this.userModel
164
+ .findByIdAndUpdate(id, dto, { new: true })
165
+ .exec();
166
+ if (!updated) throw new NotFoundException('User not found');
167
+ return updated;
168
+ }
169
+
170
+ async remove(id: string): Promise<void> {
171
+ const result = await this.userModel.findByIdAndDelete(id).exec();
172
+ if (!result) throw new NotFoundException('User not found');
173
+ }
174
+ }
175
+ ```
176
+
177
+ ### 3.4 UserValidator (implements `@codecanva/nest-auth`'s contract)
178
+
179
+ ```ts
180
+ // src/user/user.validator.ts
181
+ import { Injectable } from '@nestjs/common';
182
+ import { AuthUser, UserValidator } from '@codecanva/nest-auth';
183
+ import * as bcrypt from 'bcrypt';
184
+ import { UserService } from './user.service';
185
+ import { UserDocument } from './schemas/user.schema';
186
+
187
+ @Injectable()
188
+ export class MyUserValidator implements UserValidator {
189
+ constructor(private readonly users: UserService) {}
190
+
191
+ async validateCredentials(
192
+ email: string,
193
+ password: string,
194
+ ): Promise<AuthUser | null> {
195
+ const user = await this.users.findByEmail(email);
196
+ if (!user) return null;
197
+ const ok = await bcrypt.compare(password, user.passwordHash);
198
+ if (!ok) return null;
199
+ return this.toAuthUser(user);
200
+ }
201
+
202
+ async findById(userId: string | number): Promise<AuthUser | null> {
203
+ const user = await this.users.findById(String(userId));
204
+ return user ? this.toAuthUser(user) : null;
205
+ }
206
+
207
+ private toAuthUser(user: UserDocument): AuthUser {
208
+ return {
209
+ id: user._id.toString(),
210
+ email: user.email,
211
+ roles: user.roles,
212
+ };
213
+ }
214
+ }
215
+ ```
216
+
217
+ ### 3.5 Controller (with `@Public() POST /user/register`)
218
+
219
+ ```ts
220
+ // src/user/user.controller.ts
221
+ import {
222
+ Body,
223
+ Controller,
224
+ Delete,
225
+ Get,
226
+ Param,
227
+ Patch,
228
+ Post,
229
+ } from '@nestjs/common';
230
+ import { Public } from '@codecanva/nest-auth';
231
+ import { UserService } from './user.service';
232
+ import { CreateUserDto } from './dto/create-user.dto';
233
+ import { UpdateUserDto } from './dto/update-user.dto';
234
+
235
+ @Controller('user')
236
+ export class UserController {
237
+ constructor(private readonly userService: UserService) {}
238
+
239
+ @Public()
240
+ @Post('register')
241
+ async register(@Body() dto: CreateUserDto) {
242
+ const user = await this.userService.create(dto);
243
+ return { id: user._id.toString(), email: user.email, roles: user.roles };
244
+ }
245
+
246
+ @Get()
247
+ findAll() {
248
+ return this.userService.findAll();
249
+ }
250
+
251
+ @Get(':id')
252
+ findOne(@Param('id') id: string) {
253
+ return this.userService.findById(id);
254
+ }
255
+
256
+ @Patch(':id')
257
+ update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
258
+ return this.userService.update(id, dto);
259
+ }
260
+
261
+ @Delete(':id')
262
+ remove(@Param('id') id: string) {
263
+ return this.userService.remove(id);
264
+ }
265
+ }
266
+ ```
267
+
268
+ ### 3.6 Module (registers schema, exports validator)
269
+
270
+ ```ts
271
+ // src/user/user.module.ts
272
+ import { Module } from '@nestjs/common';
273
+ import { MongooseModule } from '@nestjs/mongoose';
274
+ import { UserService } from './user.service';
275
+ import { UserController } from './user.controller';
276
+ import { MyUserValidator } from './user.validator';
277
+ import { User, UserSchema } from './schemas/user.schema';
278
+
279
+ @Module({
280
+ imports: [
281
+ MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
282
+ ],
283
+ controllers: [UserController],
284
+ providers: [UserService, MyUserValidator],
285
+ exports: [UserService, MyUserValidator],
286
+ })
287
+ export class UserModule {}
288
+ ```
289
+
290
+ ---
291
+
292
+ ## 4. Auth module (refresh-token persistence + controller)
293
+
294
+ ### 4.1 Refresh-token schema
295
+
296
+ The `_id` is the token id chosen by the library (not Mongo's auto-id). `expiresAt` has a TTL index so expired sessions self-clean.
297
+
298
+ ```ts
299
+ // src/schemas/refresh-token.schema.ts
300
+ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
301
+ import { HydratedDocument } from 'mongoose';
302
+
303
+ export type RefreshTokenDocument = HydratedDocument<RefreshToken>;
304
+
305
+ @Schema({ collection: 'refresh_tokens', timestamps: { createdAt: true, updatedAt: false } })
306
+ export class RefreshToken {
307
+ @Prop({ type: String, required: true })
308
+ _id: string;
309
+
310
+ @Prop({ type: String, required: true, index: true })
311
+ userId: string;
312
+
313
+ @Prop({ type: String, required: true, index: true })
314
+ tokenHash: string;
315
+
316
+ @Prop({ type: Object })
317
+ metadata?: Record<string, unknown>;
318
+
319
+ @Prop({ type: Date, required: true, index: { expires: 0 } })
320
+ expiresAt: Date;
321
+
322
+ @Prop({ type: Date, default: null })
323
+ revokedAt: Date | null;
324
+ }
325
+
326
+ export const RefreshTokenSchema = SchemaFactory.createForClass(RefreshToken);
327
+ ```
328
+
329
+ ### 4.2 Store (implements `RefreshTokenStore`)
330
+
331
+ `consume` MUST be atomic — a single `findOneAndUpdate` filters on `revokedAt: null` + `tokenHash`, sets `revokedAt`, and returns the pre-update doc.
332
+
333
+ ```ts
334
+ // src/auth/refresh-token.store.ts
335
+ import { Injectable } from '@nestjs/common';
336
+ import { InjectModel } from '@nestjs/mongoose';
337
+ import { Model } from 'mongoose';
338
+ import {
339
+ CreateRefreshTokenInput,
340
+ RefreshTokenStore,
341
+ StoredRefreshToken,
342
+ } from '@codecanva/nest-auth';
343
+ import {
344
+ RefreshToken,
345
+ RefreshTokenDocument,
346
+ } from '../schemas/refresh-token.schema';
347
+
348
+ @Injectable()
349
+ export class MyRefreshTokenStore implements RefreshTokenStore {
350
+ constructor(
351
+ @InjectModel(RefreshToken.name)
352
+ private readonly model: Model<RefreshTokenDocument>,
353
+ ) {}
354
+
355
+ async create(input: CreateRefreshTokenInput): Promise<StoredRefreshToken> {
356
+ const doc = await this.model.create({
357
+ _id: input.id,
358
+ userId: String(input.userId),
359
+ tokenHash: input.tokenHash,
360
+ expiresAt: input.expiresAt,
361
+ metadata: input.metadata,
362
+ revokedAt: null,
363
+ });
364
+ return this.toStored(doc.toObject());
365
+ }
366
+
367
+ async findById(id: string): Promise<StoredRefreshToken | null> {
368
+ const doc = await this.model.findById(id).lean();
369
+ return doc ? this.toStored(doc) : null;
370
+ }
371
+
372
+ async consume(
373
+ id: string,
374
+ expectedHash: string,
375
+ ): Promise<StoredRefreshToken | null> {
376
+ const doc = await this.model
377
+ .findOneAndUpdate(
378
+ { _id: id, tokenHash: expectedHash, revokedAt: null },
379
+ { $set: { revokedAt: new Date() } },
380
+ { new: false, lean: true },
381
+ )
382
+ .exec();
383
+ return doc ? this.toStored(doc) : null;
384
+ }
385
+
386
+ async revokeById(id: string): Promise<void> {
387
+ await this.model
388
+ .updateOne(
389
+ { _id: id, revokedAt: null },
390
+ { $set: { revokedAt: new Date() } },
391
+ )
392
+ .exec();
393
+ }
394
+
395
+ async revokeAllForUser(userId: string | number): Promise<void> {
396
+ await this.model
397
+ .updateMany(
398
+ { userId: String(userId), revokedAt: null },
399
+ { $set: { revokedAt: new Date() } },
400
+ )
401
+ .exec();
402
+ }
403
+
404
+ private toStored(doc: any): StoredRefreshToken {
405
+ return {
406
+ id: String(doc._id),
407
+ userId: doc.userId,
408
+ tokenHash: doc.tokenHash,
409
+ metadata: doc.metadata,
410
+ expiresAt: doc.expiresAt,
411
+ revokedAt: doc.revokedAt ?? null,
412
+ createdAt: doc.createdAt,
413
+ };
414
+ }
415
+ }
416
+ ```
417
+
418
+ ### 4.3 Auth persistence module
419
+
420
+ ```ts
421
+ // src/auth/auth-persistence.module.ts
422
+ import { Module } from '@nestjs/common';
423
+ import { MongooseModule } from '@nestjs/mongoose';
424
+ import { MyRefreshTokenStore } from './refresh-token.store';
425
+ import {
426
+ RefreshToken,
427
+ RefreshTokenSchema,
428
+ } from '../schemas/refresh-token.schema';
429
+
430
+ @Module({
431
+ imports: [
432
+ MongooseModule.forFeature([
433
+ { name: RefreshToken.name, schema: RefreshTokenSchema },
434
+ ]),
435
+ ],
436
+ providers: [MyRefreshTokenStore],
437
+ exports: [MyRefreshTokenStore],
438
+ })
439
+ export class AuthPersistenceModule {}
440
+ ```
441
+
442
+ ### 4.4 Auth controller (login / refresh / logout / me)
443
+
444
+ The handlers return the `AuthService` calls directly — no try/catch needed.
445
+ `AuthModule` registers a global `AuthExceptionFilter` that maps the library's
446
+ domain errors to HTTP responses (`InvalidCredentialsError` → `401 Invalid
447
+ credentials`, `InvalidRefreshTokenError` / `RefreshTokenExpiredError` /
448
+ `RefreshTokenReuseDetectedError` → `401`, etc.). Without it, those errors are
449
+ plain `Error`s and Nest would render them as `500 Internal Server Error`.
450
+
451
+ ```ts
452
+ // src/auth/auth.controller.ts
453
+ import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
454
+ import {
455
+ AuthService,
456
+ AuthUser,
457
+ CurrentUser,
458
+ LoginDto,
459
+ Public,
460
+ RefreshTokenDto,
461
+ } from '@codecanva/nest-auth';
462
+
463
+ @Controller('auth')
464
+ export class AuthController {
465
+ constructor(private readonly auth: AuthService) {}
466
+
467
+ @Public()
468
+ @Post('login')
469
+ login(@Body() dto: LoginDto) {
470
+ return this.auth.login(dto.email, dto.password);
471
+ }
472
+
473
+ @Public()
474
+ @Post('refresh')
475
+ refresh(@Body() dto: RefreshTokenDto) {
476
+ return this.auth.refresh(dto.refreshToken);
477
+ }
478
+
479
+ @Post('logout')
480
+ @HttpCode(204)
481
+ logout(@Body() dto: RefreshTokenDto) {
482
+ return this.auth.logout(dto.refreshToken);
483
+ }
484
+
485
+ @Get('me')
486
+ me(@CurrentUser() user: AuthUser) {
487
+ return user;
488
+ }
489
+ }
490
+ ```
491
+
492
+ ---
493
+
494
+ ## 5. Wire everything in `AppModule`
495
+
496
+ `useExisting` points `AuthModule` at the providers already created by `UserModule` and `AuthPersistenceModule`. Registering `JwtAuthGuard` as `APP_GUARD` makes every route require auth unless marked `@Public()`.
497
+
498
+ ```ts
499
+ // src/app.module.ts
500
+ import { Module } from '@nestjs/common';
501
+ import { APP_GUARD } from '@nestjs/core';
502
+ import { ConfigModule, ConfigService } from '@nestjs/config';
503
+ import { MongooseModule } from '@nestjs/mongoose';
504
+ import { AuthModule, JwtAuthGuard } from '@codecanva/nest-auth';
505
+ import { AppController } from './app.controller';
506
+ import { AppService } from './app.service';
507
+ import { UserModule } from '@/user/user.module';
508
+ import { MyUserValidator } from '@/user/user.validator';
509
+ import { AuthPersistenceModule } from '@/auth/auth-persistence.module';
510
+ import { MyRefreshTokenStore } from '@/auth/refresh-token.store';
511
+ import { AuthController } from '@/auth/auth.controller';
512
+ import { validateEnv } from '@/config/env.validation';
513
+
514
+ @Module({
515
+ imports: [
516
+ ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }),
517
+ MongooseModule.forRootAsync({
518
+ inject: [ConfigService],
519
+ useFactory: (config: ConfigService) => ({
520
+ uri: config.getOrThrow<string>('MONGODB_URI'),
521
+ }),
522
+ }),
523
+ UserModule,
524
+ AuthPersistenceModule,
525
+ AuthModule.forRootAsync({
526
+ imports: [ConfigModule, UserModule, AuthPersistenceModule],
527
+ useFactory: (cfg: ConfigService) => ({
528
+ accessSecret: cfg.getOrThrow('JWT_ACCESS_SECRET'),
529
+ refreshSecret: cfg.getOrThrow('JWT_REFRESH_SECRET'),
530
+ accessTtl: '15m',
531
+ refreshTtl: '30d',
532
+ tokenHashPepper: cfg.get('TOKEN_HASH_PEPPER'),
533
+ issuer: 'vehcall',
534
+ }),
535
+ inject: [ConfigService],
536
+ store: { useExisting: MyRefreshTokenStore },
537
+ validator: { useExisting: MyUserValidator },
538
+ }),
539
+ ],
540
+ controllers: [AppController, AuthController],
541
+ providers: [
542
+ AppService,
543
+ { provide: APP_GUARD, useClass: JwtAuthGuard },
544
+ ],
545
+ })
546
+ export class AppModule {}
547
+ ```
548
+
549
+ Mark public routes on existing controllers (e.g. the root `/` health endpoint):
550
+
551
+ ```ts
552
+ // src/app.controller.ts
553
+ import { Controller, Get } from '@nestjs/common';
554
+ import { Public } from '@codecanva/nest-auth';
555
+ import { AppService } from './app.service';
556
+
557
+ @Controller()
558
+ export class AppController {
559
+ constructor(private readonly appService: AppService) {}
560
+
561
+ @Public()
562
+ @Get()
563
+ getHello(): string {
564
+ return this.appService.getHello();
565
+ }
566
+ }
567
+ ```
568
+
569
+ ---
570
+
571
+ ## 6. Enable validation globally in `main.ts`
572
+
573
+ ```ts
574
+ // src/main.ts
575
+ import { NestFactory } from '@nestjs/core';
576
+ import { ValidationPipe } from '@nestjs/common';
577
+ import { AppModule } from '@/app.module';
578
+ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
579
+
580
+ async function bootstrap() {
581
+ const app = await NestFactory.create(AppModule);
582
+ app.useGlobalPipes(
583
+ new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }),
584
+ );
585
+
586
+ const config = new DocumentBuilder()
587
+ .setTitle('Vehcall')
588
+ .setDescription('VehCall API')
589
+ .setVersion('1.0')
590
+ .build();
591
+ const documentFactory = () => SwaggerModule.createDocument(app, config);
592
+ SwaggerModule.setup('api', app, documentFactory);
593
+
594
+ await app.listen(process.env.PORT ?? 3000);
595
+ }
596
+ bootstrap();
597
+ ```
598
+
599
+ ---
600
+
601
+ ## 7. File tree (final)
602
+
603
+ ```
604
+ src/
605
+ ├── app.controller.ts # marked @Public() on /
606
+ ├── app.module.ts # wires AuthModule + APP_GUARD
607
+ ├── app.service.ts
608
+ ├── main.ts # global ValidationPipe
609
+ ├── auth/
610
+ │ ├── auth-persistence.module.ts
611
+ │ ├── auth.controller.ts # /auth/login, /refresh, /logout, /me
612
+ │ └── refresh-token.store.ts # MyRefreshTokenStore (atomic consume)
613
+ ├── schemas/
614
+ │ └── refresh-token.schema.ts
615
+ ├── config/
616
+ │ └── env.validation.ts # requires JWT secrets
617
+ └── user/
618
+ ├── dto/
619
+ │ ├── create-user.dto.ts # email + password validation
620
+ │ └── update-user.dto.ts
621
+ ├── schemas/
622
+ │ └── user.schema.ts
623
+ ├── user.controller.ts # @Public() POST /user/register
624
+ ├── user.module.ts # provides + exports MyUserValidator
625
+ ├── user.service.ts # bcrypt + Mongoose CRUD
626
+ └── user.validator.ts # implements UserValidator
627
+ ```
628
+
629
+ ---
630
+
631
+ ## 8. Smoke test
632
+
633
+ ```bash
634
+ npm run start:dev
635
+ ```
636
+
637
+ ```bash
638
+ # 1. Register
639
+ curl -X POST localhost:3000/user/register \
640
+ -H 'content-type: application/json' \
641
+ -d '{"email":"a@b.com","password":"password123"}'
642
+
643
+ # 2. Login → { accessToken, refreshToken, user }
644
+ curl -X POST localhost:3000/auth/login \
645
+ -H 'content-type: application/json' \
646
+ -d '{"email":"a@b.com","password":"password123"}'
647
+
648
+ # 2b. Wrong password → 401 { "message": "Invalid credentials", ... }
649
+ curl -i -X POST localhost:3000/auth/login \
650
+ -H 'content-type: application/json' \
651
+ -d '{"email":"a@b.com","password":"nope"}'
652
+
653
+ # 3. Authed request
654
+ curl localhost:3000/auth/me -H "authorization: Bearer <ACCESS_TOKEN>"
655
+
656
+ # 4. Refresh (old refreshToken is now revoked)
657
+ curl -X POST localhost:3000/auth/refresh \
658
+ -H 'content-type: application/json' \
659
+ -d '{"refreshToken":"<REFRESH_TOKEN>"}'
660
+
661
+ # 5. Replay the OLD refreshToken → 401 + every session for this user revoked
662
+ curl -X POST localhost:3000/auth/refresh \
663
+ -H 'content-type: application/json' \
664
+ -d '{"refreshToken":"<OLD_REFRESH_TOKEN>"}'
665
+
666
+ # 6. Logout
667
+ curl -X POST localhost:3000/auth/logout \
668
+ -H "authorization: Bearer <ACCESS_TOKEN>" \
669
+ -H 'content-type: application/json' \
670
+ -d '{"refreshToken":"<REFRESH_TOKEN>"}'
671
+ ```
672
+
673
+ ---
674
+
675
+ ## 9. Security checklist
676
+
677
+ - Rotate the placeholder JWT secrets and `TOKEN_HASH_PEPPER` before deploying.
678
+ - Serve auth endpoints over HTTPS only.
679
+ - Prefer storing the refresh token in an `httpOnly` cookie over returning it in JSON.
680
+ - Keep `consume` atomic — any non-atomic refactor opens a session-split window under concurrent refreshes.
681
+ - On replay detection the library calls `revokeAllForUser` — your store handles this correctly via `updateMany`.
682
+ - Never commit real `.env` values to git; `.env.example` should be the only committed template.