@ftisindia/create-app 0.1.2 → 0.1.4

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.
Files changed (109) hide show
  1. package/README.md +65 -0
  2. package/package.json +1 -1
  3. package/template/README.md +65 -1
  4. package/template/_package.json +0 -2
  5. package/template/docs/API_REFERENCE.md +13 -0
  6. package/template/docs/OAUTH.md +7 -3
  7. package/template/scripts/gen-module.mjs +2 -0
  8. package/template/src/app.module.ts +16 -22
  9. package/template/src/common/dto/error-response.dto.ts +3 -3
  10. package/template/src/common/dto/membership-response.dto.ts +26 -14
  11. package/template/src/common/dto/mutation-response.dto.ts +1 -1
  12. package/template/src/common/dto/pagination-query.dto.ts +37 -0
  13. package/template/src/common/dto/role-summary.dto.ts +5 -5
  14. package/template/src/common/dto/user-summary.dto.ts +6 -6
  15. package/template/src/common/filters/http-exception.filter.ts +9 -19
  16. package/template/src/common/swagger/api-error-responses.ts +12 -12
  17. package/template/src/config/app.config.ts +3 -3
  18. package/template/src/config/auth.config.ts +3 -3
  19. package/template/src/config/database.config.ts +3 -3
  20. package/template/src/config/env.validation.ts +58 -40
  21. package/template/src/config/index.ts +5 -5
  22. package/template/src/config/rbac.config.ts +3 -3
  23. package/template/src/database/prisma/prisma-transaction.ts +1 -1
  24. package/template/src/database/prisma/prisma.module.ts +2 -2
  25. package/template/src/database/prisma/prisma.service.ts +3 -6
  26. package/template/src/main.ts +11 -11
  27. package/template/src/modules/access-control/access-control.module.ts +9 -9
  28. package/template/src/modules/access-control/application/role-permission-policy.ts +71 -0
  29. package/template/src/modules/access-control/application/route-registry.validator.ts +34 -63
  30. package/template/src/modules/access-control/application/services/ability.factory.ts +5 -9
  31. package/template/src/modules/access-control/application/services/access-control.service.ts +78 -85
  32. package/template/src/modules/access-control/application/services/permission.guard.ts +16 -21
  33. package/template/src/modules/access-control/application/services/rbac-cache.service.ts +7 -9
  34. package/template/src/modules/access-control/dto/access-control-response.dto.ts +32 -20
  35. package/template/src/modules/access-control/dto/create-role.dto.ts +6 -6
  36. package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +3 -10
  37. package/template/src/modules/access-control/dto/update-role.dto.ts +6 -6
  38. package/template/src/modules/access-control/presentation/access-control.controller.ts +69 -74
  39. package/template/src/modules/access-control/presentation/permissions.decorator.ts +3 -3
  40. package/template/src/modules/access-control/presentation/public.decorator.ts +2 -2
  41. package/template/src/modules/access-control/types/permission-key.ts +19 -19
  42. package/template/src/modules/access-control/types/route-permission-registry.ts +76 -76
  43. package/template/src/modules/audit/application/services/audit.service.ts +7 -7
  44. package/template/src/modules/audit/audit.module.ts +4 -4
  45. package/template/src/modules/audit/dto/audit-response.dto.ts +18 -18
  46. package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +14 -14
  47. package/template/src/modules/audit/presentation/audit.controller.ts +17 -23
  48. package/template/src/modules/auth/application/services/auth.service.ts +147 -110
  49. package/template/src/modules/auth/application/services/password.service.ts +2 -2
  50. package/template/src/modules/auth/application/services/token.service.ts +20 -21
  51. package/template/src/modules/auth/auth.module.ts +20 -47
  52. package/template/src/modules/auth/dto/auth-response.dto.ts +9 -10
  53. package/template/src/modules/auth/dto/login.dto.ts +4 -4
  54. package/template/src/modules/auth/dto/logout.dto.ts +1 -1
  55. package/template/src/modules/auth/dto/oauth-exchange.dto.ts +4 -5
  56. package/template/src/modules/auth/dto/refresh-token.dto.ts +4 -5
  57. package/template/src/modules/auth/dto/signup.dto.ts +5 -11
  58. package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +6 -14
  59. package/template/src/modules/auth/infrastructure/passport/google-oauth-state.store.ts +98 -0
  60. package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +21 -30
  61. package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +3 -3
  62. package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +11 -11
  63. package/template/src/modules/auth/presentation/auth.controller.ts +45 -45
  64. package/template/src/modules/auth/presentation/current-user.decorator.ts +3 -5
  65. package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +5 -10
  66. package/template/src/modules/health/dto/health-response.dto.ts +5 -5
  67. package/template/src/modules/health/health.module.ts +2 -2
  68. package/template/src/modules/health/presentation/health.controller.ts +13 -13
  69. package/template/src/modules/invitations/application/services/invitations.service.ts +127 -176
  70. package/template/src/modules/invitations/dto/accept-invitation.dto.ts +6 -7
  71. package/template/src/modules/invitations/dto/create-invitation.dto.ts +14 -15
  72. package/template/src/modules/invitations/dto/invitation-response.dto.ts +37 -29
  73. package/template/src/modules/invitations/dto/invitation-token.dto.ts +4 -4
  74. package/template/src/modules/invitations/invitations.module.ts +5 -5
  75. package/template/src/modules/invitations/presentation/invitations.controller.ts +61 -63
  76. package/template/src/modules/memberships/application/services/memberships.service.ts +70 -84
  77. package/template/src/modules/memberships/dto/transfer-owner.dto.ts +4 -4
  78. package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +2 -2
  79. package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +2 -2
  80. package/template/src/modules/memberships/dto/update-membership-role.dto.ts +4 -4
  81. package/template/src/modules/memberships/dto/update-membership-status.dto.ts +3 -3
  82. package/template/src/modules/memberships/memberships.module.ts +4 -4
  83. package/template/src/modules/memberships/presentation/memberships.controller.ts +83 -99
  84. package/template/src/modules/organisations/application/services/organisations.service.ts +21 -23
  85. package/template/src/modules/organisations/dto/create-organisation.dto.ts +6 -13
  86. package/template/src/modules/organisations/dto/organisation-response.dto.ts +14 -14
  87. package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +4 -7
  88. package/template/src/modules/organisations/organisations.module.ts +5 -5
  89. package/template/src/modules/organisations/presentation/organisations.controller.ts +14 -23
  90. package/template/src/modules/organisations/types/default-organisation-data.ts +3 -9
  91. package/template/src/modules/request-context/application/services/request-context.service.ts +15 -7
  92. package/template/src/modules/request-context/presentation/org-scope.guard.ts +4 -9
  93. package/template/src/modules/request-context/presentation/request-context.interceptor.ts +4 -9
  94. package/template/src/modules/request-context/presentation/request-context.middleware.ts +7 -8
  95. package/template/src/modules/request-context/request-context.module.ts +7 -7
  96. package/template/src/modules/request-context/types/request-context.ts +2 -2
  97. package/template/src/modules/sample/application/services/sample.service.ts +10 -8
  98. package/template/src/modules/sample/dto/sample-echo.dto.ts +3 -3
  99. package/template/src/modules/sample/dto/sample-response.dto.ts +12 -12
  100. package/template/src/modules/sample/presentation/sample.controller.ts +25 -42
  101. package/template/src/modules/sample/sample.module.ts +4 -4
  102. package/template/src/modules/settings/application/services/settings.service.ts +15 -27
  103. package/template/src/modules/settings/dto/setting-response.dto.ts +9 -9
  104. package/template/src/modules/settings/dto/update-setting.dto.ts +5 -5
  105. package/template/src/modules/settings/presentation/settings.controller.ts +29 -35
  106. package/template/src/modules/settings/settings.module.ts +5 -5
  107. package/template/src/modules/settings/types/setting-definitions.ts +49 -33
  108. package/template/test/auth-refresh.spec.ts +90 -0
  109. package/template/test/role-permission-policy.spec.ts +94 -0
@@ -1,5 +1,5 @@
1
- import { ApiPropertyOptional } from "@nestjs/swagger";
2
- import { Type } from "class-transformer";
1
+ import { ApiPropertyOptional } from '@nestjs/swagger';
2
+ import { Type } from 'class-transformer';
3
3
  import {
4
4
  IsDateString,
5
5
  IsInt,
@@ -9,31 +9,31 @@ import {
9
9
  Max,
10
10
  MaxLength,
11
11
  Min,
12
- } from "class-validator";
12
+ } from 'class-validator';
13
13
 
14
14
  export class ListAuditLogsQueryDto {
15
- @ApiPropertyOptional({ example: "membership.status.update", maxLength: 120 })
15
+ @ApiPropertyOptional({ example: 'membership.status.update', maxLength: 120 })
16
16
  @IsOptional()
17
17
  @IsString()
18
18
  @MaxLength(120)
19
19
  action?: string;
20
20
 
21
21
  @ApiPropertyOptional({
22
- example: "4a4f0d8a-4bd2-469f-a6a9-3e1cb6a2b456",
23
- format: "uuid",
22
+ example: '4a4f0d8a-4bd2-469f-a6a9-3e1cb6a2b456',
23
+ format: 'uuid',
24
24
  })
25
25
  @IsOptional()
26
26
  @IsUUID()
27
27
  actorUserId?: string;
28
28
 
29
- @ApiPropertyOptional({ example: "Membership", maxLength: 80 })
29
+ @ApiPropertyOptional({ example: 'Membership', maxLength: 80 })
30
30
  @IsOptional()
31
31
  @IsString()
32
32
  @MaxLength(80)
33
33
  targetType?: string;
34
34
 
35
35
  @ApiPropertyOptional({
36
- example: "0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3",
36
+ example: '0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3',
37
37
  maxLength: 120,
38
38
  })
39
39
  @IsOptional()
@@ -42,16 +42,16 @@ export class ListAuditLogsQueryDto {
42
42
  targetId?: string;
43
43
 
44
44
  @ApiPropertyOptional({
45
- example: "2026-06-01T00:00:00.000Z",
46
- format: "date-time",
45
+ example: '2026-06-01T00:00:00.000Z',
46
+ format: 'date-time',
47
47
  })
48
48
  @IsOptional()
49
49
  @IsDateString()
50
50
  from?: string;
51
51
 
52
52
  @ApiPropertyOptional({
53
- example: "2026-06-02T00:00:00.000Z",
54
- format: "date-time",
53
+ example: '2026-06-02T00:00:00.000Z',
54
+ format: 'date-time',
55
55
  })
56
56
  @IsOptional()
57
57
  @IsDateString()
@@ -66,8 +66,8 @@ export class ListAuditLogsQueryDto {
66
66
  limit?: number;
67
67
 
68
68
  @ApiPropertyOptional({
69
- example: "df6537c4-f58b-452e-a67e-18ec528d0f0f",
70
- format: "uuid",
69
+ example: 'df6537c4-f58b-452e-a67e-18ec528d0f0f',
70
+ format: 'uuid',
71
71
  })
72
72
  @IsOptional()
73
73
  @IsUUID()
@@ -1,37 +1,31 @@
1
- import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
2
- import {
3
- ApiBearerAuth,
4
- ApiOkResponse,
5
- ApiOperation,
6
- ApiParam,
7
- ApiTags,
8
- } from "@nestjs/swagger";
9
- import { ApiProtectedErrorResponses } from "../../../common/swagger/api-error-responses";
10
- import { PermissionGuard } from "../../access-control/application/services/permission.guard";
11
- import { RequirePermissions } from "../../access-control/presentation/permissions.decorator";
12
- import { JwtAuthGuard } from "../../auth/infrastructure/passport/jwt-auth.guard";
13
- import { OrgScopeGuard } from "../../request-context/presentation/org-scope.guard";
14
- import { AuditService } from "../application/services/audit.service";
15
- import { AuditLogListResponseDto } from "../dto/audit-response.dto";
16
- import { ListAuditLogsQueryDto } from "../dto/list-audit-logs-query.dto";
1
+ import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
2
+ import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
3
+ import { ApiProtectedErrorResponses } from '../../../common/swagger/api-error-responses';
4
+ import { PermissionGuard } from '../../access-control/application/services/permission.guard';
5
+ import { RequirePermissions } from '../../access-control/presentation/permissions.decorator';
6
+ import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
7
+ import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
8
+ import { AuditService } from '../application/services/audit.service';
9
+ import { AuditLogListResponseDto } from '../dto/audit-response.dto';
10
+ import { ListAuditLogsQueryDto } from '../dto/list-audit-logs-query.dto';
17
11
 
18
- @ApiTags("Audit")
12
+ @ApiTags('Audit')
19
13
  @ApiBearerAuth()
20
- @ApiParam({ name: "orgId", description: "Organisation ID.", format: "uuid" })
14
+ @ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
21
15
  @ApiProtectedErrorResponses()
22
- @Controller("organisations/:orgId/audit")
16
+ @Controller('organisations/:orgId/audit')
23
17
  @UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
24
18
  export class AuditController {
25
19
  constructor(private readonly auditService: AuditService) {}
26
20
 
27
21
  @Get()
28
- @RequirePermissions("audit.read")
29
- @ApiOperation({ summary: "List audit logs for an organisation." })
22
+ @RequirePermissions('audit.read')
23
+ @ApiOperation({ summary: 'List audit logs for an organisation.' })
30
24
  @ApiOkResponse({
31
- description: "Audit log page.",
25
+ description: 'Audit log page.',
32
26
  type: AuditLogListResponseDto,
33
27
  })
34
- list(@Param("orgId") orgId: string, @Query() query: ListAuditLogsQueryDto) {
28
+ list(@Param('orgId') orgId: string, @Query() query: ListAuditLogsQueryDto) {
35
29
  return this.auditService.listOrgLogs(orgId, query);
36
30
  }
37
31
  }
@@ -3,26 +3,34 @@ import {
3
3
  ForbiddenException,
4
4
  Injectable,
5
5
  UnauthorizedException,
6
- } from "@nestjs/common";
7
- import { ConfigService } from "@nestjs/config";
8
- import { AuthProvider, Prisma, User } from "@prisma/client";
9
- import { createHash, randomBytes } from "crypto";
10
- import { PrismaService } from "../../../../database/prisma/prisma.service";
11
- import { LoginDto } from "../../dto/login.dto";
12
- import { LogoutDto } from "../../dto/logout.dto";
13
- import { OAuthExchangeDto } from "../../dto/oauth-exchange.dto";
14
- import { RefreshTokenDto } from "../../dto/refresh-token.dto";
15
- import { SignupDto } from "../../dto/signup.dto";
16
- import { AuthenticatedUser } from "../../types/authenticated-user";
17
- import { GoogleAuthProfile } from "../../types/google-auth-profile";
18
- import { PasswordService } from "./password.service";
19
- import { TokenService } from "./token.service";
20
-
21
- const DUMMY_PASSWORD_HASH =
22
- "$2b$12$1rpQSsxHfzUmzLtIlCH5Hu4KDsKz.NNTkQ3kqMT1kJxCgW4YodNTm";
6
+ } from '@nestjs/common';
7
+ import { ConfigService } from '@nestjs/config';
8
+ import { AuthProvider, Prisma, User } from '@prisma/client';
9
+ import { createHash, randomBytes } from 'crypto';
10
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
11
+ import { RequestContextService } from '../../../request-context/application/services/request-context.service';
12
+ import { LoginDto } from '../../dto/login.dto';
13
+ import { LogoutDto } from '../../dto/logout.dto';
14
+ import { OAuthExchangeDto } from '../../dto/oauth-exchange.dto';
15
+ import { RefreshTokenDto } from '../../dto/refresh-token.dto';
16
+ import { SignupDto } from '../../dto/signup.dto';
17
+ import { AuthenticatedUser } from '../../types/authenticated-user';
18
+ import { GoogleAuthProfile } from '../../types/google-auth-profile';
19
+ import { PasswordService } from './password.service';
20
+ import { TokenService } from './token.service';
21
+
22
+ const DUMMY_PASSWORD_HASH = '$2b$12$1rpQSsxHfzUmzLtIlCH5Hu4KDsKz.NNTkQ3kqMT1kJxCgW4YodNTm';
23
23
  const OAUTH_EXCHANGE_CODE_TTL_MS = 5 * 60 * 1000;
24
- const SIGNUP_CONFLICT_MESSAGE =
25
- "Unable to complete signup with the provided details.";
24
+ const SIGNUP_CONFLICT_MESSAGE = 'Unable to complete signup with the provided details.';
25
+
26
+ type RefreshResult =
27
+ | {
28
+ status: 'rotated';
29
+ refreshToken: string;
30
+ }
31
+ | {
32
+ status: 'reuse_detected' | 'invalid';
33
+ };
26
34
 
27
35
  @Injectable()
28
36
  export class AuthService {
@@ -30,6 +38,7 @@ export class AuthService {
30
38
  private readonly config: ConfigService,
31
39
  private readonly passwordService: PasswordService,
32
40
  private readonly prisma: PrismaService,
41
+ private readonly requestContext: RequestContextService,
33
42
  private readonly tokenService: TokenService,
34
43
  ) {}
35
44
 
@@ -68,11 +77,11 @@ export class AuthService {
68
77
  });
69
78
 
70
79
  await this.writeAudit(tx, {
71
- action: "auth.signup",
80
+ action: 'auth.signup',
72
81
  actorUserId: createdUser.id,
73
- targetType: "User",
82
+ targetType: 'User',
74
83
  targetId: createdUser.id,
75
- metadata: { provider: "email_password", email },
84
+ metadata: { provider: 'email_password', email },
76
85
  });
77
86
 
78
87
  return createdUser;
@@ -102,21 +111,18 @@ export class AuthService {
102
111
  throw invalidCredentials();
103
112
  }
104
113
 
105
- const passwordMatches = await this.passwordService.verify(
106
- dto.password,
107
- user.passwordHash,
108
- );
114
+ const passwordMatches = await this.passwordService.verify(dto.password, user.passwordHash);
109
115
  if (!passwordMatches) {
110
116
  await this.recordFailedLogin(email, user.id);
111
117
  throw invalidCredentials();
112
118
  }
113
119
 
114
120
  await this.writeAudit(this.prisma, {
115
- action: "auth.login.success",
121
+ action: 'auth.login.success',
116
122
  actorUserId: user.id,
117
- targetType: "User",
123
+ targetType: 'User',
118
124
  targetId: user.id,
119
- metadata: { provider: "email_password", email },
125
+ metadata: { provider: 'email_password', email },
120
126
  });
121
127
 
122
128
  return this.buildAuthResponse(user);
@@ -145,36 +151,67 @@ export class AuthService {
145
151
  throw invalidCredentials();
146
152
  }
147
153
 
148
- const result = await this.prisma.$transaction(async (tx) => {
149
- const next = await this.tokenService.createRefreshToken(
150
- existing.userId,
151
- tx,
152
- );
154
+ const result: RefreshResult = await this.prisma.$transaction(async (tx) => {
155
+ // Atomically claim the rotation: only the request that flips revokedAt
156
+ // from null wins. Under READ COMMITTED a concurrent refresh presenting
157
+ // the same token blocks on the row lock, then re-evaluates this WHERE
158
+ // against the committed row, matches zero rows, and is rejected — so
159
+ // exactly one replacement is ever minted and no live token escapes the
160
+ // reuse-detection chain. The replacement is created only after winning.
161
+ const claimed = await tx.refreshToken.updateMany({
162
+ where: { id: existing.id, revokedAt: null },
163
+ data: { revokedAt: new Date() },
164
+ });
165
+
166
+ if (claimed.count === 0) {
167
+ // Lost the race to a concurrent refresh of the same token. The genuine
168
+ // stolen-token replay is handled here as well: after the winning
169
+ // transaction commits, the row is visible with a replacement pointer,
170
+ // so revoke the replacement family in this transaction before returning
171
+ // 401 outside the transaction. Do not throw here, or Prisma rolls back
172
+ // the revocation.
173
+ const current = await tx.refreshToken.findUnique({
174
+ where: { id: existing.id },
175
+ select: { replacedByTokenId: true },
176
+ });
177
+ if (current?.replacedByTokenId) {
178
+ await this.recordRefreshTokenReuse(tx, existing.id, existing.userId);
179
+ return { status: 'reuse_detected' };
180
+ }
181
+
182
+ return { status: 'invalid' };
183
+ }
184
+
185
+ const next = await this.tokenService.createRefreshToken(existing.userId, tx);
153
186
 
154
187
  await tx.refreshToken.update({
155
188
  where: { id: existing.id },
156
- data: {
157
- revokedAt: new Date(),
158
- replacedByTokenId: next.record.id,
159
- },
189
+ data: { replacedByTokenId: next.record.id },
160
190
  });
161
191
 
162
192
  await this.writeAudit(tx, {
163
- action: "auth.refresh",
193
+ action: 'auth.refresh',
164
194
  actorUserId: existing.userId,
165
- targetType: "RefreshToken",
195
+ targetType: 'RefreshToken',
166
196
  targetId: existing.id,
167
197
  metadata: { replacedByTokenId: next.record.id },
168
198
  });
169
199
 
170
- return next.refreshToken;
200
+ return {
201
+ status: 'rotated',
202
+ refreshToken: next.refreshToken,
203
+ };
171
204
  });
172
205
 
206
+ if (result.status !== 'rotated') {
207
+ throw invalidCredentials();
208
+ }
209
+
173
210
  return {
174
211
  accessToken: await this.tokenService.signAccessToken(existing.user),
175
- refreshToken: result,
176
- tokenType: "Bearer",
177
- expiresIn: this.tokenService.getAccessTokenTtl(),
212
+ refreshToken: result.refreshToken,
213
+ tokenType: 'Bearer',
214
+ expiresIn: this.tokenService.getAccessTokenTtlSeconds(),
178
215
  user: toSafeUser(existing.user),
179
216
  };
180
217
  }
@@ -197,9 +234,9 @@ export class AuthService {
197
234
  });
198
235
 
199
236
  await this.writeAudit(tx, {
200
- action: "auth.logout",
237
+ action: 'auth.logout',
201
238
  actorUserId: token.userId,
202
- targetType: "RefreshToken",
239
+ targetType: 'RefreshToken',
203
240
  targetId: token.id,
204
241
  });
205
242
  });
@@ -224,17 +261,24 @@ export class AuthService {
224
261
  }
225
262
 
226
263
  await this.prisma.$transaction(async (tx) => {
227
- await tx.authExchangeCode.update({
228
- where: { id: existing.id },
264
+ // Atomically claim the one-time code: only the request that flips usedAt
265
+ // from null wins, so concurrent exchanges of the same code can't both
266
+ // mint a session. The loser matches zero rows and is rejected.
267
+ const claimed = await tx.authExchangeCode.updateMany({
268
+ where: { id: existing.id, usedAt: null },
229
269
  data: { usedAt: new Date() },
230
270
  });
231
271
 
272
+ if (claimed.count === 0) {
273
+ throw invalidCredentials();
274
+ }
275
+
232
276
  await this.writeAudit(tx, {
233
- action: "auth.oauth.exchange",
277
+ action: 'auth.oauth.exchange',
234
278
  actorUserId: existing.userId,
235
- targetType: "AuthExchangeCode",
279
+ targetType: 'AuthExchangeCode',
236
280
  targetId: existing.id,
237
- metadata: { provider: "google" },
281
+ metadata: { provider: 'google' },
238
282
  });
239
283
  });
240
284
 
@@ -245,7 +289,7 @@ export class AuthService {
245
289
  this.assertGoogleEnabled();
246
290
 
247
291
  if (!profile.emailVerified) {
248
- throw new UnauthorizedException("Google email address is not verified.");
292
+ throw new UnauthorizedException('Google email address is not verified.');
249
293
  }
250
294
 
251
295
  const email = normalizeEmail(profile.email);
@@ -266,11 +310,11 @@ export class AuthService {
266
310
  }
267
311
 
268
312
  await this.writeAudit(tx, {
269
- action: "auth.google.login",
313
+ action: 'auth.google.login',
270
314
  actorUserId: existingIdentity.userId,
271
- targetType: "User",
315
+ targetType: 'User',
272
316
  targetId: existingIdentity.userId,
273
- metadata: { provider: "google", email },
317
+ metadata: { provider: 'google', email },
274
318
  });
275
319
 
276
320
  return existingIdentity.user;
@@ -295,11 +339,11 @@ export class AuthService {
295
339
  });
296
340
 
297
341
  await this.writeAudit(tx, {
298
- action: "auth.google.link",
342
+ action: 'auth.google.link',
299
343
  actorUserId: existingUser.id,
300
- targetType: "User",
344
+ targetType: 'User',
301
345
  targetId: existingUser.id,
302
- metadata: { provider: "google", email },
346
+ metadata: { provider: 'google', email },
303
347
  });
304
348
 
305
349
  return existingUser;
@@ -322,21 +366,19 @@ export class AuthService {
322
366
  });
323
367
 
324
368
  await this.writeAudit(tx, {
325
- action: "auth.google.signup",
369
+ action: 'auth.google.signup',
326
370
  actorUserId: createdUser.id,
327
- targetType: "User",
371
+ targetType: 'User',
328
372
  targetId: createdUser.id,
329
- metadata: { provider: "google", email },
373
+ metadata: { provider: 'google', email },
330
374
  });
331
375
 
332
376
  return createdUser;
333
377
  });
334
378
 
335
379
  const code = await this.createOAuthExchangeCode(user.id);
336
- const redirectUrl = new URL(
337
- this.config.get<string>("auth.google.successRedirectUrl") ?? "",
338
- );
339
- redirectUrl.searchParams.set("code", code);
380
+ const redirectUrl = new URL(this.config.get<string>('auth.google.successRedirectUrl') ?? '');
381
+ redirectUrl.searchParams.set('code', code);
340
382
 
341
383
  return redirectUrl.toString();
342
384
  }
@@ -351,14 +393,14 @@ export class AuthService {
351
393
  return {
352
394
  accessToken: await this.tokenService.signAccessToken(user),
353
395
  refreshToken: refresh.refreshToken,
354
- tokenType: "Bearer",
355
- expiresIn: this.tokenService.getAccessTokenTtl(),
396
+ tokenType: 'Bearer',
397
+ expiresIn: this.tokenService.getAccessTokenTtlSeconds(),
356
398
  user: toSafeUser(user),
357
399
  };
358
400
  }
359
401
 
360
402
  private async createOAuthExchangeCode(userId: string) {
361
- const code = randomBytes(48).toString("base64url");
403
+ const code = randomBytes(48).toString('base64url');
362
404
  await this.prisma.authExchangeCode.create({
363
405
  data: {
364
406
  userId,
@@ -370,54 +412,51 @@ export class AuthService {
370
412
  return code;
371
413
  }
372
414
 
373
- private async handleRefreshTokenReuse(
415
+ private async handleRefreshTokenReuse(refreshTokenId: string, userId: string) {
416
+ await this.prisma.$transaction(async (tx) => {
417
+ await this.recordRefreshTokenReuse(tx, refreshTokenId, userId);
418
+ });
419
+ }
420
+
421
+ private async recordRefreshTokenReuse(
422
+ tx: Prisma.TransactionClient,
374
423
  refreshTokenId: string,
375
424
  userId: string,
376
425
  ) {
377
- await this.prisma.$transaction(async (tx) => {
378
- const now = new Date();
379
- const revokedIds = await revokeRefreshTokenDescendants(
380
- tx,
381
- refreshTokenId,
382
- now,
383
- );
384
-
385
- await this.writeAudit(tx, {
386
- action: "auth.refresh.reuse_detected",
387
- actorUserId: userId,
388
- targetType: "RefreshToken",
389
- targetId: refreshTokenId,
390
- metadata: {
391
- revokedTokenIds: revokedIds,
392
- },
393
- });
426
+ const now = new Date();
427
+ const revokedIds = await revokeRefreshTokenDescendants(tx, refreshTokenId, now);
428
+
429
+ await this.writeAudit(tx, {
430
+ action: 'auth.refresh.reuse_detected',
431
+ actorUserId: userId,
432
+ targetType: 'RefreshToken',
433
+ targetId: refreshTokenId,
434
+ metadata: {
435
+ revokedTokenIds: revokedIds,
436
+ },
394
437
  });
395
438
  }
396
439
 
397
440
  private assertEmailPasswordEnabled() {
398
- const enabled = this.config.get<boolean>(
399
- "auth.providers.emailPasswordEnabled",
400
- );
441
+ const enabled = this.config.get<boolean>('auth.providers.emailPasswordEnabled');
401
442
  if (!enabled) {
402
- throw new ForbiddenException(
403
- "Email/password authentication is disabled.",
404
- );
443
+ throw new ForbiddenException('Email/password authentication is disabled.');
405
444
  }
406
445
  }
407
446
 
408
447
  private assertGoogleEnabled() {
409
- const enabled = this.config.get<boolean>("auth.providers.googleEnabled");
448
+ const enabled = this.config.get<boolean>('auth.providers.googleEnabled');
410
449
  if (!enabled) {
411
- throw new ForbiddenException("Google authentication is disabled.");
450
+ throw new ForbiddenException('Google authentication is disabled.');
412
451
  }
413
452
  }
414
453
 
415
454
  private async recordFailedLogin(email: string, actorUserId?: string) {
416
455
  await this.writeAudit(this.prisma, {
417
- action: "auth.login.failed",
456
+ action: 'auth.login.failed',
418
457
  actorUserId,
419
- targetType: "Auth",
420
- metadata: { provider: "email_password", email },
458
+ targetType: 'Auth',
459
+ metadata: { provider: 'email_password', email },
421
460
  });
422
461
  }
423
462
 
@@ -439,13 +478,15 @@ export class AuthService {
439
478
  targetType: data.targetType,
440
479
  targetId: data.targetId,
441
480
  metadata: data.metadata,
481
+ ipAddress: this.requestContext.getIpAddress(),
482
+ userAgent: this.requestContext.getUserAgent(),
442
483
  },
443
484
  });
444
485
  }
445
486
  }
446
487
 
447
488
  function hashOneTimeCode(code: string) {
448
- return createHash("sha256").update(code).digest("hex");
489
+ return createHash('sha256').update(code).digest('hex');
449
490
  }
450
491
 
451
492
  async function revokeRefreshTokenDescendants(
@@ -457,13 +498,12 @@ async function revokeRefreshTokenDescendants(
457
498
  let cursor: string | null = refreshTokenId;
458
499
 
459
500
  while (cursor) {
460
- const token: { replacedByTokenId: string | null } | null =
461
- await tx.refreshToken.findUnique({
462
- where: { id: cursor },
463
- select: {
464
- replacedByTokenId: true,
465
- },
466
- });
501
+ const token: { replacedByTokenId: string | null } | null = await tx.refreshToken.findUnique({
502
+ where: { id: cursor },
503
+ select: {
504
+ replacedByTokenId: true,
505
+ },
506
+ });
467
507
 
468
508
  cursor = token?.replacedByTokenId ?? null;
469
509
  if (cursor) {
@@ -488,14 +528,11 @@ function normalizeEmail(email: string) {
488
528
  }
489
529
 
490
530
  function invalidCredentials() {
491
- return new UnauthorizedException("Invalid credentials.");
531
+ return new UnauthorizedException('Invalid credentials.');
492
532
  }
493
533
 
494
534
  function isPrismaUniqueError(error: unknown) {
495
- return (
496
- error instanceof Prisma.PrismaClientKnownRequestError &&
497
- error.code === "P2002"
498
- );
535
+ return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
499
536
  }
500
537
 
501
538
  function toSafeUser(user: User): AuthenticatedUser {
@@ -1,5 +1,5 @@
1
- import { Injectable } from "@nestjs/common";
2
- import * as bcrypt from "bcryptjs";
1
+ import { Injectable } from '@nestjs/common';
2
+ import * as bcrypt from 'bcryptjs';
3
3
 
4
4
  const PASSWORD_HASH_ROUNDS = 12;
5
5
 
@@ -1,10 +1,10 @@
1
- import { randomBytes, createHash } from "node:crypto";
2
- import { InternalServerErrorException, Injectable } from "@nestjs/common";
3
- import { ConfigService } from "@nestjs/config";
4
- import { JwtService } from "@nestjs/jwt";
5
- import { Prisma } from "@prisma/client";
6
- import { PrismaService } from "../../../../database/prisma/prisma.service";
7
- import { JwtPayload } from "../../types/jwt-payload";
1
+ import { randomBytes, createHash } from 'node:crypto';
2
+ import { InternalServerErrorException, Injectable } from '@nestjs/common';
3
+ import { ConfigService } from '@nestjs/config';
4
+ import { JwtService } from '@nestjs/jwt';
5
+ import { Prisma } from '@prisma/client';
6
+ import { PrismaService } from '../../../../database/prisma/prisma.service';
7
+ import { JwtPayload } from '../../types/jwt-payload';
8
8
 
9
9
  type TokenUser = {
10
10
  id: string;
@@ -31,17 +31,14 @@ export class TokenService {
31
31
 
32
32
  return this.jwt.signAsync(payload, {
33
33
  expiresIn: this.getAccessTokenTtl() as never,
34
- algorithm: "HS256",
35
- issuer: this.config.get<string>("auth.jwt.issuer"),
36
- audience: this.config.get<string>("auth.jwt.audience"),
34
+ algorithm: 'HS256',
35
+ issuer: this.config.get<string>('auth.jwt.issuer'),
36
+ audience: this.config.get<string>('auth.jwt.audience'),
37
37
  });
38
38
  }
39
39
 
40
- async createRefreshToken(
41
- userId: string,
42
- client: RefreshTokenStore = this.prisma,
43
- ) {
44
- const refreshToken = randomBytes(48).toString("base64url");
40
+ async createRefreshToken(userId: string, client: RefreshTokenStore = this.prisma) {
41
+ const refreshToken = randomBytes(48).toString('base64url');
45
42
  const tokenHash = this.hashRefreshToken(refreshToken);
46
43
  const expiresAt = this.getRefreshTokenExpiry();
47
44
 
@@ -60,15 +57,19 @@ export class TokenService {
60
57
  }
61
58
 
62
59
  hashRefreshToken(refreshToken: string) {
63
- return createHash("sha256").update(refreshToken).digest("hex");
60
+ return createHash('sha256').update(refreshToken).digest('hex');
64
61
  }
65
62
 
66
63
  getAccessTokenTtl() {
67
- return this.config.get<string>("auth.jwt.accessExpiresIn") ?? "15m";
64
+ return this.config.get<string>('auth.jwt.accessExpiresIn') ?? '15m';
65
+ }
66
+
67
+ getAccessTokenTtlSeconds() {
68
+ return Math.floor(parseDurationToMs(this.getAccessTokenTtl()) / 1000);
68
69
  }
69
70
 
70
71
  private getRefreshTokenExpiry() {
71
- const ttl = this.config.get<string>("auth.jwt.refreshExpiresIn") ?? "7d";
72
+ const ttl = this.config.get<string>('auth.jwt.refreshExpiresIn') ?? '7d';
72
73
  return new Date(Date.now() + parseDurationToMs(ttl));
73
74
  }
74
75
  }
@@ -76,9 +77,7 @@ export class TokenService {
76
77
  function parseDurationToMs(value: string) {
77
78
  const match = /^(\d+)([smhdw])$/.exec(value.trim());
78
79
  if (!match) {
79
- throw new InternalServerErrorException(
80
- `Unsupported duration value: ${value}`,
81
- );
80
+ throw new InternalServerErrorException(`Unsupported duration value: ${value}`);
82
81
  }
83
82
 
84
83
  const amount = Number(match[1]);