@ftisindia/create-app 0.1.3 → 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.
- package/package.json +1 -1
- package/template/README.md +1 -1
- package/template/_package.json +0 -2
- package/template/docs/API_REFERENCE.md +13 -0
- package/template/docs/OAUTH.md +7 -3
- package/template/scripts/gen-module.mjs +2 -0
- package/template/src/app.module.ts +16 -22
- package/template/src/common/dto/error-response.dto.ts +3 -3
- package/template/src/common/dto/membership-response.dto.ts +26 -14
- package/template/src/common/dto/mutation-response.dto.ts +1 -1
- package/template/src/common/dto/pagination-query.dto.ts +37 -0
- package/template/src/common/dto/role-summary.dto.ts +5 -5
- package/template/src/common/dto/user-summary.dto.ts +6 -6
- package/template/src/common/filters/http-exception.filter.ts +9 -19
- package/template/src/common/swagger/api-error-responses.ts +12 -12
- package/template/src/config/app.config.ts +3 -3
- package/template/src/config/auth.config.ts +3 -3
- package/template/src/config/database.config.ts +3 -3
- package/template/src/config/env.validation.ts +58 -40
- package/template/src/config/index.ts +5 -5
- package/template/src/config/rbac.config.ts +3 -3
- package/template/src/database/prisma/prisma-transaction.ts +1 -1
- package/template/src/database/prisma/prisma.module.ts +2 -2
- package/template/src/database/prisma/prisma.service.ts +3 -6
- package/template/src/main.ts +11 -11
- package/template/src/modules/access-control/access-control.module.ts +9 -9
- package/template/src/modules/access-control/application/role-permission-policy.ts +71 -0
- package/template/src/modules/access-control/application/route-registry.validator.ts +34 -63
- package/template/src/modules/access-control/application/services/ability.factory.ts +5 -9
- package/template/src/modules/access-control/application/services/access-control.service.ts +78 -85
- package/template/src/modules/access-control/application/services/permission.guard.ts +16 -21
- package/template/src/modules/access-control/application/services/rbac-cache.service.ts +7 -9
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +32 -20
- package/template/src/modules/access-control/dto/create-role.dto.ts +6 -6
- package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +3 -10
- package/template/src/modules/access-control/dto/update-role.dto.ts +6 -6
- package/template/src/modules/access-control/presentation/access-control.controller.ts +69 -74
- package/template/src/modules/access-control/presentation/permissions.decorator.ts +3 -3
- package/template/src/modules/access-control/presentation/public.decorator.ts +2 -2
- package/template/src/modules/access-control/types/permission-key.ts +19 -19
- package/template/src/modules/access-control/types/route-permission-registry.ts +76 -76
- package/template/src/modules/audit/application/services/audit.service.ts +7 -7
- package/template/src/modules/audit/audit.module.ts +4 -4
- package/template/src/modules/audit/dto/audit-response.dto.ts +18 -18
- package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +14 -14
- package/template/src/modules/audit/presentation/audit.controller.ts +17 -23
- package/template/src/modules/auth/application/services/auth.service.ts +147 -110
- package/template/src/modules/auth/application/services/password.service.ts +2 -2
- package/template/src/modules/auth/application/services/token.service.ts +20 -21
- package/template/src/modules/auth/auth.module.ts +20 -47
- package/template/src/modules/auth/dto/auth-response.dto.ts +9 -10
- package/template/src/modules/auth/dto/login.dto.ts +4 -4
- package/template/src/modules/auth/dto/logout.dto.ts +1 -1
- package/template/src/modules/auth/dto/oauth-exchange.dto.ts +4 -5
- package/template/src/modules/auth/dto/refresh-token.dto.ts +4 -5
- package/template/src/modules/auth/dto/signup.dto.ts +5 -11
- package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +6 -14
- package/template/src/modules/auth/infrastructure/passport/google-oauth-state.store.ts +98 -0
- package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +21 -30
- package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +3 -3
- package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +11 -11
- package/template/src/modules/auth/presentation/auth.controller.ts +45 -45
- package/template/src/modules/auth/presentation/current-user.decorator.ts +3 -5
- package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +5 -10
- package/template/src/modules/health/dto/health-response.dto.ts +5 -5
- package/template/src/modules/health/health.module.ts +2 -2
- package/template/src/modules/health/presentation/health.controller.ts +13 -13
- package/template/src/modules/invitations/application/services/invitations.service.ts +127 -176
- package/template/src/modules/invitations/dto/accept-invitation.dto.ts +6 -7
- package/template/src/modules/invitations/dto/create-invitation.dto.ts +14 -15
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +37 -29
- package/template/src/modules/invitations/dto/invitation-token.dto.ts +4 -4
- package/template/src/modules/invitations/invitations.module.ts +5 -5
- package/template/src/modules/invitations/presentation/invitations.controller.ts +61 -63
- package/template/src/modules/memberships/application/services/memberships.service.ts +70 -84
- package/template/src/modules/memberships/dto/transfer-owner.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-role.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-membership-status.dto.ts +3 -3
- package/template/src/modules/memberships/memberships.module.ts +4 -4
- package/template/src/modules/memberships/presentation/memberships.controller.ts +83 -99
- package/template/src/modules/organisations/application/services/organisations.service.ts +21 -23
- package/template/src/modules/organisations/dto/create-organisation.dto.ts +6 -13
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +14 -14
- package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +4 -7
- package/template/src/modules/organisations/organisations.module.ts +5 -5
- package/template/src/modules/organisations/presentation/organisations.controller.ts +14 -23
- package/template/src/modules/organisations/types/default-organisation-data.ts +3 -9
- package/template/src/modules/request-context/application/services/request-context.service.ts +15 -7
- package/template/src/modules/request-context/presentation/org-scope.guard.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.interceptor.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.middleware.ts +7 -8
- package/template/src/modules/request-context/request-context.module.ts +7 -7
- package/template/src/modules/request-context/types/request-context.ts +2 -2
- package/template/src/modules/sample/application/services/sample.service.ts +10 -8
- package/template/src/modules/sample/dto/sample-echo.dto.ts +3 -3
- package/template/src/modules/sample/dto/sample-response.dto.ts +12 -12
- package/template/src/modules/sample/presentation/sample.controller.ts +25 -42
- package/template/src/modules/sample/sample.module.ts +4 -4
- package/template/src/modules/settings/application/services/settings.service.ts +15 -27
- package/template/src/modules/settings/dto/setting-response.dto.ts +9 -9
- package/template/src/modules/settings/dto/update-setting.dto.ts +5 -5
- package/template/src/modules/settings/presentation/settings.controller.ts +29 -35
- package/template/src/modules/settings/settings.module.ts +5 -5
- package/template/src/modules/settings/types/setting-definitions.ts +49 -33
- package/template/test/auth-refresh.spec.ts +90 -0
- package/template/test/role-permission-policy.spec.ts +94 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ApiPropertyOptional } from
|
|
2
|
-
import { Type } from
|
|
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
|
|
12
|
+
} from 'class-validator';
|
|
13
13
|
|
|
14
14
|
export class ListAuditLogsQueryDto {
|
|
15
|
-
@ApiPropertyOptional({ example:
|
|
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:
|
|
23
|
-
format:
|
|
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:
|
|
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:
|
|
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:
|
|
46
|
-
format:
|
|
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:
|
|
54
|
-
format:
|
|
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:
|
|
70
|
-
format:
|
|
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
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} from
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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(
|
|
12
|
+
@ApiTags('Audit')
|
|
19
13
|
@ApiBearerAuth()
|
|
20
|
-
@ApiParam({ name:
|
|
14
|
+
@ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
|
|
21
15
|
@ApiProtectedErrorResponses()
|
|
22
|
-
@Controller(
|
|
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(
|
|
29
|
-
@ApiOperation({ summary:
|
|
22
|
+
@RequirePermissions('audit.read')
|
|
23
|
+
@ApiOperation({ summary: 'List audit logs for an organisation.' })
|
|
30
24
|
@ApiOkResponse({
|
|
31
|
-
description:
|
|
25
|
+
description: 'Audit log page.',
|
|
32
26
|
type: AuditLogListResponseDto,
|
|
33
27
|
})
|
|
34
|
-
list(@Param(
|
|
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
|
|
7
|
-
import { ConfigService } from
|
|
8
|
-
import { AuthProvider, Prisma, User } from
|
|
9
|
-
import { createHash, randomBytes } from
|
|
10
|
-
import { PrismaService } from
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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:
|
|
80
|
+
action: 'auth.signup',
|
|
72
81
|
actorUserId: createdUser.id,
|
|
73
|
-
targetType:
|
|
82
|
+
targetType: 'User',
|
|
74
83
|
targetId: createdUser.id,
|
|
75
|
-
metadata: { provider:
|
|
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:
|
|
121
|
+
action: 'auth.login.success',
|
|
116
122
|
actorUserId: user.id,
|
|
117
|
-
targetType:
|
|
123
|
+
targetType: 'User',
|
|
118
124
|
targetId: user.id,
|
|
119
|
-
metadata: { provider:
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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:
|
|
193
|
+
action: 'auth.refresh',
|
|
164
194
|
actorUserId: existing.userId,
|
|
165
|
-
targetType:
|
|
195
|
+
targetType: 'RefreshToken',
|
|
166
196
|
targetId: existing.id,
|
|
167
197
|
metadata: { replacedByTokenId: next.record.id },
|
|
168
198
|
});
|
|
169
199
|
|
|
170
|
-
return
|
|
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:
|
|
177
|
-
expiresIn: this.tokenService.
|
|
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:
|
|
237
|
+
action: 'auth.logout',
|
|
201
238
|
actorUserId: token.userId,
|
|
202
|
-
targetType:
|
|
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
|
-
|
|
228
|
-
|
|
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:
|
|
277
|
+
action: 'auth.oauth.exchange',
|
|
234
278
|
actorUserId: existing.userId,
|
|
235
|
-
targetType:
|
|
279
|
+
targetType: 'AuthExchangeCode',
|
|
236
280
|
targetId: existing.id,
|
|
237
|
-
metadata: { provider:
|
|
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(
|
|
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:
|
|
313
|
+
action: 'auth.google.login',
|
|
270
314
|
actorUserId: existingIdentity.userId,
|
|
271
|
-
targetType:
|
|
315
|
+
targetType: 'User',
|
|
272
316
|
targetId: existingIdentity.userId,
|
|
273
|
-
metadata: { provider:
|
|
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:
|
|
342
|
+
action: 'auth.google.link',
|
|
299
343
|
actorUserId: existingUser.id,
|
|
300
|
-
targetType:
|
|
344
|
+
targetType: 'User',
|
|
301
345
|
targetId: existingUser.id,
|
|
302
|
-
metadata: { provider:
|
|
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:
|
|
369
|
+
action: 'auth.google.signup',
|
|
326
370
|
actorUserId: createdUser.id,
|
|
327
|
-
targetType:
|
|
371
|
+
targetType: 'User',
|
|
328
372
|
targetId: createdUser.id,
|
|
329
|
-
metadata: { provider:
|
|
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
|
-
|
|
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:
|
|
355
|
-
expiresIn: this.tokenService.
|
|
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(
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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>(
|
|
448
|
+
const enabled = this.config.get<boolean>('auth.providers.googleEnabled');
|
|
410
449
|
if (!enabled) {
|
|
411
|
-
throw new ForbiddenException(
|
|
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:
|
|
456
|
+
action: 'auth.login.failed',
|
|
418
457
|
actorUserId,
|
|
419
|
-
targetType:
|
|
420
|
-
metadata: { provider:
|
|
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(
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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(
|
|
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,10 +1,10 @@
|
|
|
1
|
-
import { randomBytes, createHash } from
|
|
2
|
-
import { InternalServerErrorException, Injectable } from
|
|
3
|
-
import { ConfigService } from
|
|
4
|
-
import { JwtService } from
|
|
5
|
-
import { Prisma } from
|
|
6
|
-
import { PrismaService } from
|
|
7
|
-
import { JwtPayload } from
|
|
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:
|
|
35
|
-
issuer: this.config.get<string>(
|
|
36
|
-
audience: this.config.get<string>(
|
|
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
|
-
|
|
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(
|
|
60
|
+
return createHash('sha256').update(refreshToken).digest('hex');
|
|
64
61
|
}
|
|
65
62
|
|
|
66
63
|
getAccessTokenTtl() {
|
|
67
|
-
return this.config.get<string>(
|
|
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>(
|
|
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]);
|