@ftisindia/create-app 0.1.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.
Files changed (151) hide show
  1. package/LICENSE +144 -0
  2. package/bin/index.mjs +2 -0
  3. package/package.json +36 -0
  4. package/src/copy.mjs +45 -0
  5. package/src/log.mjs +23 -0
  6. package/src/main.mjs +221 -0
  7. package/src/run.mjs +52 -0
  8. package/src/substitute.mjs +40 -0
  9. package/template/.env.example +36 -0
  10. package/template/.github/workflows/ci.yml +36 -0
  11. package/template/.husky/pre-commit +1 -0
  12. package/template/README.md +146 -0
  13. package/template/_editorconfig +8 -0
  14. package/template/_gitignore +7 -0
  15. package/template/_nvmrc +1 -0
  16. package/template/_package.json +107 -0
  17. package/template/_prettierignore +5 -0
  18. package/template/_prettierrc +6 -0
  19. package/template/docs/API_REFERENCE.md +123 -0
  20. package/template/docs/GETTING_STARTED.md +65 -0
  21. package/template/docs/MODULE_COMPLETION_CHECKLIST.md +40 -0
  22. package/template/docs/OAUTH.md +46 -0
  23. package/template/docs/SAMPLE_MODULE.md +23 -0
  24. package/template/docs/api.http +269 -0
  25. package/template/eslint.config.mjs +51 -0
  26. package/template/nest-cli.json +8 -0
  27. package/template/prisma/migrations/20260530000000_init/migration.sql +248 -0
  28. package/template/prisma/schema.prisma +299 -0
  29. package/template/prisma/seed.ts +44 -0
  30. package/template/scripts/db-create.mjs +126 -0
  31. package/template/scripts/gen-module.mjs +217 -0
  32. package/template/scripts/seed-test-user-org.ts +264 -0
  33. package/template/scripts/setup-local.mjs +224 -0
  34. package/template/scripts/test-db.mjs +69 -0
  35. package/template/src/app.module.ts +58 -0
  36. package/template/src/common/decorators/.gitkeep +1 -0
  37. package/template/src/common/dto/error-response.dto.ts +17 -0
  38. package/template/src/common/dto/membership-response.dto.ts +51 -0
  39. package/template/src/common/dto/mutation-response.dto.ts +11 -0
  40. package/template/src/common/dto/role-summary.dto.ts +18 -0
  41. package/template/src/common/dto/user-summary.dto.ts +23 -0
  42. package/template/src/common/enums/.gitkeep +1 -0
  43. package/template/src/common/filters/.gitkeep +1 -0
  44. package/template/src/common/filters/http-exception.filter.ts +78 -0
  45. package/template/src/common/guards/.gitkeep +1 -0
  46. package/template/src/common/interceptors/.gitkeep +1 -0
  47. package/template/src/common/pipes/.gitkeep +1 -0
  48. package/template/src/common/swagger/api-error-responses.ts +54 -0
  49. package/template/src/common/types/.gitkeep +1 -0
  50. package/template/src/config/app.config.ts +7 -0
  51. package/template/src/config/auth.config.ts +33 -0
  52. package/template/src/config/database.config.ts +6 -0
  53. package/template/src/config/env.validation.ts +131 -0
  54. package/template/src/config/index.ts +5 -0
  55. package/template/src/config/rbac.config.ts +6 -0
  56. package/template/src/database/prisma/prisma-transaction.ts +22 -0
  57. package/template/src/database/prisma/prisma.module.ts +9 -0
  58. package/template/src/database/prisma/prisma.service.ts +16 -0
  59. package/template/src/main.ts +42 -0
  60. package/template/src/modules/access-control/access-control.module.ts +24 -0
  61. package/template/src/modules/access-control/application/route-registry.validator.ts +289 -0
  62. package/template/src/modules/access-control/application/services/ability.factory.ts +28 -0
  63. package/template/src/modules/access-control/application/services/access-control.service.ts +478 -0
  64. package/template/src/modules/access-control/application/services/permission.guard.ts +77 -0
  65. package/template/src/modules/access-control/application/services/rbac-cache.service.ts +148 -0
  66. package/template/src/modules/access-control/dto/access-control-response.dto.ts +79 -0
  67. package/template/src/modules/access-control/dto/create-role.dto.ts +18 -0
  68. package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +23 -0
  69. package/template/src/modules/access-control/dto/update-role.dto.ts +19 -0
  70. package/template/src/modules/access-control/presentation/access-control.controller.ts +157 -0
  71. package/template/src/modules/access-control/presentation/permissions.decorator.ts +8 -0
  72. package/template/src/modules/access-control/presentation/public.decorator.ts +7 -0
  73. package/template/src/modules/access-control/types/permission-key.ts +37 -0
  74. package/template/src/modules/access-control/types/rbac-context.ts +11 -0
  75. package/template/src/modules/access-control/types/route-permission-registry.ts +129 -0
  76. package/template/src/modules/audit/application/services/audit.service.ts +97 -0
  77. package/template/src/modules/audit/audit.module.ts +13 -0
  78. package/template/src/modules/audit/dto/audit-response.dto.ts +75 -0
  79. package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +75 -0
  80. package/template/src/modules/audit/presentation/audit.controller.ts +37 -0
  81. package/template/src/modules/auth/application/services/auth.service.ts +509 -0
  82. package/template/src/modules/auth/application/services/password.service.ts +15 -0
  83. package/template/src/modules/auth/application/services/token.service.ts +95 -0
  84. package/template/src/modules/auth/auth.module.ts +73 -0
  85. package/template/src/modules/auth/dto/auth-response.dto.ts +29 -0
  86. package/template/src/modules/auth/dto/login.dto.ts +15 -0
  87. package/template/src/modules/auth/dto/logout.dto.ts +3 -0
  88. package/template/src/modules/auth/dto/oauth-exchange.dto.ts +15 -0
  89. package/template/src/modules/auth/dto/refresh-token.dto.ts +14 -0
  90. package/template/src/modules/auth/dto/signup.dto.ts +27 -0
  91. package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +27 -0
  92. package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +56 -0
  93. package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +5 -0
  94. package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +43 -0
  95. package/template/src/modules/auth/presentation/auth.controller.ts +148 -0
  96. package/template/src/modules/auth/presentation/current-user.decorator.ts +11 -0
  97. package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +33 -0
  98. package/template/src/modules/auth/types/authenticated-user.ts +7 -0
  99. package/template/src/modules/auth/types/google-auth-profile.ts +6 -0
  100. package/template/src/modules/auth/types/jwt-payload.ts +5 -0
  101. package/template/src/modules/health/dto/health-response.dto.ts +9 -0
  102. package/template/src/modules/health/health.module.ts +7 -0
  103. package/template/src/modules/health/presentation/health.controller.ts +33 -0
  104. package/template/src/modules/invitations/application/services/invitations.service.ts +967 -0
  105. package/template/src/modules/invitations/dto/accept-invitation.dto.ts +24 -0
  106. package/template/src/modules/invitations/dto/create-invitation.dto.ts +100 -0
  107. package/template/src/modules/invitations/dto/invitation-response.dto.ts +108 -0
  108. package/template/src/modules/invitations/dto/invitation-token.dto.ts +15 -0
  109. package/template/src/modules/invitations/invitations.module.ts +12 -0
  110. package/template/src/modules/invitations/presentation/invitations.controller.ts +149 -0
  111. package/template/src/modules/memberships/application/services/memberships.service.ts +455 -0
  112. package/template/src/modules/memberships/dto/transfer-owner.dto.ts +11 -0
  113. package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +8 -0
  114. package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +8 -0
  115. package/template/src/modules/memberships/dto/update-membership-role.dto.ts +11 -0
  116. package/template/src/modules/memberships/dto/update-membership-status.dto.ts +9 -0
  117. package/template/src/modules/memberships/memberships.module.ts +12 -0
  118. package/template/src/modules/memberships/presentation/memberships.controller.ts +193 -0
  119. package/template/src/modules/organisations/application/services/organisations.service.ts +147 -0
  120. package/template/src/modules/organisations/dto/create-organisation.dto.ts +32 -0
  121. package/template/src/modules/organisations/dto/organisation-response.dto.ts +62 -0
  122. package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +24 -0
  123. package/template/src/modules/organisations/organisations.module.ts +12 -0
  124. package/template/src/modules/organisations/presentation/organisations.controller.ts +37 -0
  125. package/template/src/modules/organisations/types/default-organisation-data.ts +18 -0
  126. package/template/src/modules/platform-admin/.gitkeep +1 -0
  127. package/template/src/modules/request-context/application/services/request-context.service.ts +79 -0
  128. package/template/src/modules/request-context/presentation/org-scope.guard.ts +26 -0
  129. package/template/src/modules/request-context/presentation/request-context.interceptor.ts +26 -0
  130. package/template/src/modules/request-context/presentation/request-context.middleware.ts +31 -0
  131. package/template/src/modules/request-context/request-context.module.ts +25 -0
  132. package/template/src/modules/request-context/types/request-context.ts +29 -0
  133. package/template/src/modules/sample/application/services/sample.service.ts +67 -0
  134. package/template/src/modules/sample/dto/sample-echo.dto.ts +10 -0
  135. package/template/src/modules/sample/dto/sample-response.dto.ts +41 -0
  136. package/template/src/modules/sample/presentation/sample.controller.ts +63 -0
  137. package/template/src/modules/sample/sample.module.ts +11 -0
  138. package/template/src/modules/settings/application/services/settings.service.ts +139 -0
  139. package/template/src/modules/settings/dto/setting-response.dto.ts +27 -0
  140. package/template/src/modules/settings/dto/update-setting.dto.ts +16 -0
  141. package/template/src/modules/settings/presentation/settings.controller.ts +66 -0
  142. package/template/src/modules/settings/settings.module.ts +12 -0
  143. package/template/src/modules/settings/types/setting-definitions.ts +104 -0
  144. package/template/src/modules/users/.gitkeep +1 -0
  145. package/template/test/.gitkeep +1 -0
  146. package/template/test/jest-e2e.json +9 -0
  147. package/template/test/permission.guard.spec.ts +22 -0
  148. package/template/test/route-registry.validator.spec.ts +90 -0
  149. package/template/test/security.e2e-spec.ts +102 -0
  150. package/template/tsconfig.build.json +4 -0
  151. package/template/tsconfig.json +18 -0
@@ -0,0 +1,75 @@
1
+ import { ApiPropertyOptional } from "@nestjs/swagger";
2
+ import { Type } from "class-transformer";
3
+ import {
4
+ IsDateString,
5
+ IsInt,
6
+ IsOptional,
7
+ IsString,
8
+ IsUUID,
9
+ Max,
10
+ MaxLength,
11
+ Min,
12
+ } from "class-validator";
13
+
14
+ export class ListAuditLogsQueryDto {
15
+ @ApiPropertyOptional({ example: "membership.status.update", maxLength: 120 })
16
+ @IsOptional()
17
+ @IsString()
18
+ @MaxLength(120)
19
+ action?: string;
20
+
21
+ @ApiPropertyOptional({
22
+ example: "4a4f0d8a-4bd2-469f-a6a9-3e1cb6a2b456",
23
+ format: "uuid",
24
+ })
25
+ @IsOptional()
26
+ @IsUUID()
27
+ actorUserId?: string;
28
+
29
+ @ApiPropertyOptional({ example: "Membership", maxLength: 80 })
30
+ @IsOptional()
31
+ @IsString()
32
+ @MaxLength(80)
33
+ targetType?: string;
34
+
35
+ @ApiPropertyOptional({
36
+ example: "0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3",
37
+ maxLength: 120,
38
+ })
39
+ @IsOptional()
40
+ @IsString()
41
+ @MaxLength(120)
42
+ targetId?: string;
43
+
44
+ @ApiPropertyOptional({
45
+ example: "2026-06-01T00:00:00.000Z",
46
+ format: "date-time",
47
+ })
48
+ @IsOptional()
49
+ @IsDateString()
50
+ from?: string;
51
+
52
+ @ApiPropertyOptional({
53
+ example: "2026-06-02T00:00:00.000Z",
54
+ format: "date-time",
55
+ })
56
+ @IsOptional()
57
+ @IsDateString()
58
+ to?: string;
59
+
60
+ @ApiPropertyOptional({ example: 50, minimum: 1, maximum: 100 })
61
+ @IsOptional()
62
+ @Type(() => Number)
63
+ @IsInt()
64
+ @Min(1)
65
+ @Max(100)
66
+ limit?: number;
67
+
68
+ @ApiPropertyOptional({
69
+ example: "df6537c4-f58b-452e-a67e-18ec528d0f0f",
70
+ format: "uuid",
71
+ })
72
+ @IsOptional()
73
+ @IsUUID()
74
+ cursor?: string;
75
+ }
@@ -0,0 +1,37 @@
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";
17
+
18
+ @ApiTags("Audit")
19
+ @ApiBearerAuth()
20
+ @ApiParam({ name: "orgId", description: "Organisation ID.", format: "uuid" })
21
+ @ApiProtectedErrorResponses()
22
+ @Controller("organisations/:orgId/audit")
23
+ @UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
24
+ export class AuditController {
25
+ constructor(private readonly auditService: AuditService) {}
26
+
27
+ @Get()
28
+ @RequirePermissions("audit.read")
29
+ @ApiOperation({ summary: "List audit logs for an organisation." })
30
+ @ApiOkResponse({
31
+ description: "Audit log page.",
32
+ type: AuditLogListResponseDto,
33
+ })
34
+ list(@Param("orgId") orgId: string, @Query() query: ListAuditLogsQueryDto) {
35
+ return this.auditService.listOrgLogs(orgId, query);
36
+ }
37
+ }
@@ -0,0 +1,509 @@
1
+ import {
2
+ ConflictException,
3
+ ForbiddenException,
4
+ Injectable,
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";
23
+ const OAUTH_EXCHANGE_CODE_TTL_MS = 5 * 60 * 1000;
24
+ const SIGNUP_CONFLICT_MESSAGE =
25
+ "Unable to complete signup with the provided details.";
26
+
27
+ @Injectable()
28
+ export class AuthService {
29
+ constructor(
30
+ private readonly config: ConfigService,
31
+ private readonly passwordService: PasswordService,
32
+ private readonly prisma: PrismaService,
33
+ private readonly tokenService: TokenService,
34
+ ) {}
35
+
36
+ async signup(dto: SignupDto) {
37
+ this.assertEmailPasswordEnabled();
38
+
39
+ const email = normalizeEmail(dto.email);
40
+ const existing = await this.prisma.user.findUnique({
41
+ where: { email },
42
+ select: { id: true },
43
+ });
44
+
45
+ if (existing) {
46
+ throw new ConflictException(SIGNUP_CONFLICT_MESSAGE);
47
+ }
48
+
49
+ const passwordHash = await this.passwordService.hash(dto.password);
50
+ let user: User;
51
+ try {
52
+ user = await this.prisma.$transaction(async (tx) => {
53
+ const createdUser = await tx.user.create({
54
+ data: {
55
+ email,
56
+ passwordHash,
57
+ displayName: dto.displayName?.trim() || null,
58
+ },
59
+ });
60
+
61
+ await tx.authIdentity.create({
62
+ data: {
63
+ userId: createdUser.id,
64
+ provider: AuthProvider.EMAIL_PASSWORD,
65
+ providerUserId: email,
66
+ email,
67
+ },
68
+ });
69
+
70
+ await this.writeAudit(tx, {
71
+ action: "auth.signup",
72
+ actorUserId: createdUser.id,
73
+ targetType: "User",
74
+ targetId: createdUser.id,
75
+ metadata: { provider: "email_password", email },
76
+ });
77
+
78
+ return createdUser;
79
+ });
80
+ } catch (error) {
81
+ if (isPrismaUniqueError(error)) {
82
+ throw new ConflictException(SIGNUP_CONFLICT_MESSAGE);
83
+ }
84
+
85
+ throw error;
86
+ }
87
+
88
+ return this.buildAuthResponse(user);
89
+ }
90
+
91
+ async login(dto: LoginDto) {
92
+ this.assertEmailPasswordEnabled();
93
+
94
+ const email = normalizeEmail(dto.email);
95
+ const user = await this.prisma.user.findUnique({
96
+ where: { email },
97
+ });
98
+
99
+ if (!user?.passwordHash || !user.isActive) {
100
+ await this.passwordService.verify(dto.password, DUMMY_PASSWORD_HASH);
101
+ await this.recordFailedLogin(email);
102
+ throw invalidCredentials();
103
+ }
104
+
105
+ const passwordMatches = await this.passwordService.verify(
106
+ dto.password,
107
+ user.passwordHash,
108
+ );
109
+ if (!passwordMatches) {
110
+ await this.recordFailedLogin(email, user.id);
111
+ throw invalidCredentials();
112
+ }
113
+
114
+ await this.writeAudit(this.prisma, {
115
+ action: "auth.login.success",
116
+ actorUserId: user.id,
117
+ targetType: "User",
118
+ targetId: user.id,
119
+ metadata: { provider: "email_password", email },
120
+ });
121
+
122
+ return this.buildAuthResponse(user);
123
+ }
124
+
125
+ async refresh(dto: RefreshTokenDto) {
126
+ const tokenHash = this.tokenService.hashRefreshToken(dto.refreshToken);
127
+ const existing = await this.prisma.refreshToken.findUnique({
128
+ where: { tokenHash },
129
+ include: { user: true },
130
+ });
131
+
132
+ if (!existing) {
133
+ throw invalidCredentials();
134
+ }
135
+
136
+ if (existing.revokedAt) {
137
+ if (existing.replacedByTokenId) {
138
+ await this.handleRefreshTokenReuse(existing.id, existing.userId);
139
+ }
140
+
141
+ throw invalidCredentials();
142
+ }
143
+
144
+ if (existing.expiresAt <= new Date() || !existing.user.isActive) {
145
+ throw invalidCredentials();
146
+ }
147
+
148
+ const result = await this.prisma.$transaction(async (tx) => {
149
+ const next = await this.tokenService.createRefreshToken(
150
+ existing.userId,
151
+ tx,
152
+ );
153
+
154
+ await tx.refreshToken.update({
155
+ where: { id: existing.id },
156
+ data: {
157
+ revokedAt: new Date(),
158
+ replacedByTokenId: next.record.id,
159
+ },
160
+ });
161
+
162
+ await this.writeAudit(tx, {
163
+ action: "auth.refresh",
164
+ actorUserId: existing.userId,
165
+ targetType: "RefreshToken",
166
+ targetId: existing.id,
167
+ metadata: { replacedByTokenId: next.record.id },
168
+ });
169
+
170
+ return next.refreshToken;
171
+ });
172
+
173
+ return {
174
+ accessToken: await this.tokenService.signAccessToken(existing.user),
175
+ refreshToken: result,
176
+ tokenType: "Bearer",
177
+ expiresIn: this.tokenService.getAccessTokenTtl(),
178
+ user: toSafeUser(existing.user),
179
+ };
180
+ }
181
+
182
+ async logout(dto: LogoutDto) {
183
+ const tokenHash = this.tokenService.hashRefreshToken(dto.refreshToken);
184
+ const token = await this.prisma.refreshToken.findUnique({
185
+ where: { tokenHash },
186
+ select: { id: true, userId: true, revokedAt: true },
187
+ });
188
+
189
+ if (!token || token.revokedAt) {
190
+ return { revoked: true };
191
+ }
192
+
193
+ await this.prisma.$transaction(async (tx) => {
194
+ await tx.refreshToken.update({
195
+ where: { id: token.id },
196
+ data: { revokedAt: new Date() },
197
+ });
198
+
199
+ await this.writeAudit(tx, {
200
+ action: "auth.logout",
201
+ actorUserId: token.userId,
202
+ targetType: "RefreshToken",
203
+ targetId: token.id,
204
+ });
205
+ });
206
+
207
+ return { revoked: true };
208
+ }
209
+
210
+ async exchangeOAuthCode(dto: OAuthExchangeDto) {
211
+ const tokenHash = hashOneTimeCode(dto.code);
212
+ const existing = await this.prisma.authExchangeCode.findUnique({
213
+ where: { tokenHash },
214
+ include: { user: true },
215
+ });
216
+
217
+ if (
218
+ !existing ||
219
+ existing.usedAt ||
220
+ existing.expiresAt <= new Date() ||
221
+ !existing.user.isActive
222
+ ) {
223
+ throw invalidCredentials();
224
+ }
225
+
226
+ await this.prisma.$transaction(async (tx) => {
227
+ await tx.authExchangeCode.update({
228
+ where: { id: existing.id },
229
+ data: { usedAt: new Date() },
230
+ });
231
+
232
+ await this.writeAudit(tx, {
233
+ action: "auth.oauth.exchange",
234
+ actorUserId: existing.userId,
235
+ targetType: "AuthExchangeCode",
236
+ targetId: existing.id,
237
+ metadata: { provider: "google" },
238
+ });
239
+ });
240
+
241
+ return this.buildAuthResponse(existing.user);
242
+ }
243
+
244
+ async googleLogin(profile: GoogleAuthProfile) {
245
+ this.assertGoogleEnabled();
246
+
247
+ if (!profile.emailVerified) {
248
+ throw new UnauthorizedException("Google email address is not verified.");
249
+ }
250
+
251
+ const email = normalizeEmail(profile.email);
252
+ const user = await this.prisma.$transaction(async (tx) => {
253
+ const existingIdentity = await tx.authIdentity.findUnique({
254
+ where: {
255
+ provider_providerUserId: {
256
+ provider: AuthProvider.GOOGLE,
257
+ providerUserId: profile.providerUserId,
258
+ },
259
+ },
260
+ include: { user: true },
261
+ });
262
+
263
+ if (existingIdentity) {
264
+ if (!existingIdentity.user.isActive) {
265
+ throw invalidCredentials();
266
+ }
267
+
268
+ await this.writeAudit(tx, {
269
+ action: "auth.google.login",
270
+ actorUserId: existingIdentity.userId,
271
+ targetType: "User",
272
+ targetId: existingIdentity.userId,
273
+ metadata: { provider: "google", email },
274
+ });
275
+
276
+ return existingIdentity.user;
277
+ }
278
+
279
+ const existingUser = await tx.user.findUnique({
280
+ where: { email },
281
+ });
282
+
283
+ if (existingUser) {
284
+ if (!existingUser.isActive) {
285
+ throw invalidCredentials();
286
+ }
287
+
288
+ await tx.authIdentity.create({
289
+ data: {
290
+ userId: existingUser.id,
291
+ provider: AuthProvider.GOOGLE,
292
+ providerUserId: profile.providerUserId,
293
+ email,
294
+ },
295
+ });
296
+
297
+ await this.writeAudit(tx, {
298
+ action: "auth.google.link",
299
+ actorUserId: existingUser.id,
300
+ targetType: "User",
301
+ targetId: existingUser.id,
302
+ metadata: { provider: "google", email },
303
+ });
304
+
305
+ return existingUser;
306
+ }
307
+
308
+ const createdUser = await tx.user.create({
309
+ data: {
310
+ email,
311
+ displayName: profile.displayName,
312
+ },
313
+ });
314
+
315
+ await tx.authIdentity.create({
316
+ data: {
317
+ userId: createdUser.id,
318
+ provider: AuthProvider.GOOGLE,
319
+ providerUserId: profile.providerUserId,
320
+ email,
321
+ },
322
+ });
323
+
324
+ await this.writeAudit(tx, {
325
+ action: "auth.google.signup",
326
+ actorUserId: createdUser.id,
327
+ targetType: "User",
328
+ targetId: createdUser.id,
329
+ metadata: { provider: "google", email },
330
+ });
331
+
332
+ return createdUser;
333
+ });
334
+
335
+ 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);
340
+
341
+ return redirectUrl.toString();
342
+ }
343
+
344
+ me(user: AuthenticatedUser) {
345
+ return user;
346
+ }
347
+
348
+ private async buildAuthResponse(user: User) {
349
+ const refresh = await this.tokenService.createRefreshToken(user.id);
350
+
351
+ return {
352
+ accessToken: await this.tokenService.signAccessToken(user),
353
+ refreshToken: refresh.refreshToken,
354
+ tokenType: "Bearer",
355
+ expiresIn: this.tokenService.getAccessTokenTtl(),
356
+ user: toSafeUser(user),
357
+ };
358
+ }
359
+
360
+ private async createOAuthExchangeCode(userId: string) {
361
+ const code = randomBytes(48).toString("base64url");
362
+ await this.prisma.authExchangeCode.create({
363
+ data: {
364
+ userId,
365
+ tokenHash: hashOneTimeCode(code),
366
+ expiresAt: new Date(Date.now() + OAUTH_EXCHANGE_CODE_TTL_MS),
367
+ },
368
+ });
369
+
370
+ return code;
371
+ }
372
+
373
+ private async handleRefreshTokenReuse(
374
+ refreshTokenId: string,
375
+ userId: string,
376
+ ) {
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
+ });
394
+ });
395
+ }
396
+
397
+ private assertEmailPasswordEnabled() {
398
+ const enabled = this.config.get<boolean>(
399
+ "auth.providers.emailPasswordEnabled",
400
+ );
401
+ if (!enabled) {
402
+ throw new ForbiddenException(
403
+ "Email/password authentication is disabled.",
404
+ );
405
+ }
406
+ }
407
+
408
+ private assertGoogleEnabled() {
409
+ const enabled = this.config.get<boolean>("auth.providers.googleEnabled");
410
+ if (!enabled) {
411
+ throw new ForbiddenException("Google authentication is disabled.");
412
+ }
413
+ }
414
+
415
+ private async recordFailedLogin(email: string, actorUserId?: string) {
416
+ await this.writeAudit(this.prisma, {
417
+ action: "auth.login.failed",
418
+ actorUserId,
419
+ targetType: "Auth",
420
+ metadata: { provider: "email_password", email },
421
+ });
422
+ }
423
+
424
+ private async writeAudit(
425
+ client: Prisma.TransactionClient | PrismaService,
426
+ data: {
427
+ action: string;
428
+ actorUserId?: string;
429
+ targetType: string;
430
+ targetId?: string;
431
+ metadata?: Prisma.InputJsonObject;
432
+ },
433
+ ) {
434
+ await client.auditLog.create({
435
+ data: {
436
+ orgId: null,
437
+ actorUserId: data.actorUserId,
438
+ action: data.action,
439
+ targetType: data.targetType,
440
+ targetId: data.targetId,
441
+ metadata: data.metadata,
442
+ },
443
+ });
444
+ }
445
+ }
446
+
447
+ function hashOneTimeCode(code: string) {
448
+ return createHash("sha256").update(code).digest("hex");
449
+ }
450
+
451
+ async function revokeRefreshTokenDescendants(
452
+ tx: Prisma.TransactionClient,
453
+ refreshTokenId: string,
454
+ revokedAt: Date,
455
+ ): Promise<string[]> {
456
+ const revokedIds = new Set<string>([refreshTokenId]);
457
+ let cursor: string | null = refreshTokenId;
458
+
459
+ 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
+ });
467
+
468
+ cursor = token?.replacedByTokenId ?? null;
469
+ if (cursor) {
470
+ revokedIds.add(cursor);
471
+ }
472
+ }
473
+
474
+ await tx.refreshToken.updateMany({
475
+ where: {
476
+ id: {
477
+ in: [...revokedIds],
478
+ },
479
+ },
480
+ data: { revokedAt },
481
+ });
482
+
483
+ return [...revokedIds];
484
+ }
485
+
486
+ function normalizeEmail(email: string) {
487
+ return email.trim().toLowerCase();
488
+ }
489
+
490
+ function invalidCredentials() {
491
+ return new UnauthorizedException("Invalid credentials.");
492
+ }
493
+
494
+ function isPrismaUniqueError(error: unknown) {
495
+ return (
496
+ error instanceof Prisma.PrismaClientKnownRequestError &&
497
+ error.code === "P2002"
498
+ );
499
+ }
500
+
501
+ function toSafeUser(user: User): AuthenticatedUser {
502
+ return {
503
+ id: user.id,
504
+ email: user.email,
505
+ mobile: user.mobile,
506
+ displayName: user.displayName,
507
+ isActive: user.isActive,
508
+ };
509
+ }
@@ -0,0 +1,15 @@
1
+ import { Injectable } from "@nestjs/common";
2
+ import * as bcrypt from "bcryptjs";
3
+
4
+ const PASSWORD_HASH_ROUNDS = 12;
5
+
6
+ @Injectable()
7
+ export class PasswordService {
8
+ hash(password: string) {
9
+ return bcrypt.hash(password, PASSWORD_HASH_ROUNDS);
10
+ }
11
+
12
+ verify(password: string, hash: string) {
13
+ return bcrypt.compare(password, hash);
14
+ }
15
+ }