@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.
- package/LICENSE +144 -0
- package/bin/index.mjs +2 -0
- package/package.json +36 -0
- package/src/copy.mjs +45 -0
- package/src/log.mjs +23 -0
- package/src/main.mjs +221 -0
- package/src/run.mjs +52 -0
- package/src/substitute.mjs +40 -0
- package/template/.env.example +36 -0
- package/template/.github/workflows/ci.yml +36 -0
- package/template/.husky/pre-commit +1 -0
- package/template/README.md +146 -0
- package/template/_editorconfig +8 -0
- package/template/_gitignore +7 -0
- package/template/_nvmrc +1 -0
- package/template/_package.json +107 -0
- package/template/_prettierignore +5 -0
- package/template/_prettierrc +6 -0
- package/template/docs/API_REFERENCE.md +123 -0
- package/template/docs/GETTING_STARTED.md +65 -0
- package/template/docs/MODULE_COMPLETION_CHECKLIST.md +40 -0
- package/template/docs/OAUTH.md +46 -0
- package/template/docs/SAMPLE_MODULE.md +23 -0
- package/template/docs/api.http +269 -0
- package/template/eslint.config.mjs +51 -0
- package/template/nest-cli.json +8 -0
- package/template/prisma/migrations/20260530000000_init/migration.sql +248 -0
- package/template/prisma/schema.prisma +299 -0
- package/template/prisma/seed.ts +44 -0
- package/template/scripts/db-create.mjs +126 -0
- package/template/scripts/gen-module.mjs +217 -0
- package/template/scripts/seed-test-user-org.ts +264 -0
- package/template/scripts/setup-local.mjs +224 -0
- package/template/scripts/test-db.mjs +69 -0
- package/template/src/app.module.ts +58 -0
- package/template/src/common/decorators/.gitkeep +1 -0
- package/template/src/common/dto/error-response.dto.ts +17 -0
- package/template/src/common/dto/membership-response.dto.ts +51 -0
- package/template/src/common/dto/mutation-response.dto.ts +11 -0
- package/template/src/common/dto/role-summary.dto.ts +18 -0
- package/template/src/common/dto/user-summary.dto.ts +23 -0
- package/template/src/common/enums/.gitkeep +1 -0
- package/template/src/common/filters/.gitkeep +1 -0
- package/template/src/common/filters/http-exception.filter.ts +78 -0
- package/template/src/common/guards/.gitkeep +1 -0
- package/template/src/common/interceptors/.gitkeep +1 -0
- package/template/src/common/pipes/.gitkeep +1 -0
- package/template/src/common/swagger/api-error-responses.ts +54 -0
- package/template/src/common/types/.gitkeep +1 -0
- package/template/src/config/app.config.ts +7 -0
- package/template/src/config/auth.config.ts +33 -0
- package/template/src/config/database.config.ts +6 -0
- package/template/src/config/env.validation.ts +131 -0
- package/template/src/config/index.ts +5 -0
- package/template/src/config/rbac.config.ts +6 -0
- package/template/src/database/prisma/prisma-transaction.ts +22 -0
- package/template/src/database/prisma/prisma.module.ts +9 -0
- package/template/src/database/prisma/prisma.service.ts +16 -0
- package/template/src/main.ts +42 -0
- package/template/src/modules/access-control/access-control.module.ts +24 -0
- package/template/src/modules/access-control/application/route-registry.validator.ts +289 -0
- package/template/src/modules/access-control/application/services/ability.factory.ts +28 -0
- package/template/src/modules/access-control/application/services/access-control.service.ts +478 -0
- package/template/src/modules/access-control/application/services/permission.guard.ts +77 -0
- package/template/src/modules/access-control/application/services/rbac-cache.service.ts +148 -0
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +79 -0
- package/template/src/modules/access-control/dto/create-role.dto.ts +18 -0
- package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +23 -0
- package/template/src/modules/access-control/dto/update-role.dto.ts +19 -0
- package/template/src/modules/access-control/presentation/access-control.controller.ts +157 -0
- package/template/src/modules/access-control/presentation/permissions.decorator.ts +8 -0
- package/template/src/modules/access-control/presentation/public.decorator.ts +7 -0
- package/template/src/modules/access-control/types/permission-key.ts +37 -0
- package/template/src/modules/access-control/types/rbac-context.ts +11 -0
- package/template/src/modules/access-control/types/route-permission-registry.ts +129 -0
- package/template/src/modules/audit/application/services/audit.service.ts +97 -0
- package/template/src/modules/audit/audit.module.ts +13 -0
- package/template/src/modules/audit/dto/audit-response.dto.ts +75 -0
- package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +75 -0
- package/template/src/modules/audit/presentation/audit.controller.ts +37 -0
- package/template/src/modules/auth/application/services/auth.service.ts +509 -0
- package/template/src/modules/auth/application/services/password.service.ts +15 -0
- package/template/src/modules/auth/application/services/token.service.ts +95 -0
- package/template/src/modules/auth/auth.module.ts +73 -0
- package/template/src/modules/auth/dto/auth-response.dto.ts +29 -0
- package/template/src/modules/auth/dto/login.dto.ts +15 -0
- package/template/src/modules/auth/dto/logout.dto.ts +3 -0
- package/template/src/modules/auth/dto/oauth-exchange.dto.ts +15 -0
- package/template/src/modules/auth/dto/refresh-token.dto.ts +14 -0
- package/template/src/modules/auth/dto/signup.dto.ts +27 -0
- package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +27 -0
- package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +56 -0
- package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +5 -0
- package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +43 -0
- package/template/src/modules/auth/presentation/auth.controller.ts +148 -0
- package/template/src/modules/auth/presentation/current-user.decorator.ts +11 -0
- package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +33 -0
- package/template/src/modules/auth/types/authenticated-user.ts +7 -0
- package/template/src/modules/auth/types/google-auth-profile.ts +6 -0
- package/template/src/modules/auth/types/jwt-payload.ts +5 -0
- package/template/src/modules/health/dto/health-response.dto.ts +9 -0
- package/template/src/modules/health/health.module.ts +7 -0
- package/template/src/modules/health/presentation/health.controller.ts +33 -0
- package/template/src/modules/invitations/application/services/invitations.service.ts +967 -0
- package/template/src/modules/invitations/dto/accept-invitation.dto.ts +24 -0
- package/template/src/modules/invitations/dto/create-invitation.dto.ts +100 -0
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +108 -0
- package/template/src/modules/invitations/dto/invitation-token.dto.ts +15 -0
- package/template/src/modules/invitations/invitations.module.ts +12 -0
- package/template/src/modules/invitations/presentation/invitations.controller.ts +149 -0
- package/template/src/modules/memberships/application/services/memberships.service.ts +455 -0
- package/template/src/modules/memberships/dto/transfer-owner.dto.ts +11 -0
- package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +8 -0
- package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +8 -0
- package/template/src/modules/memberships/dto/update-membership-role.dto.ts +11 -0
- package/template/src/modules/memberships/dto/update-membership-status.dto.ts +9 -0
- package/template/src/modules/memberships/memberships.module.ts +12 -0
- package/template/src/modules/memberships/presentation/memberships.controller.ts +193 -0
- package/template/src/modules/organisations/application/services/organisations.service.ts +147 -0
- package/template/src/modules/organisations/dto/create-organisation.dto.ts +32 -0
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +62 -0
- package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +24 -0
- package/template/src/modules/organisations/organisations.module.ts +12 -0
- package/template/src/modules/organisations/presentation/organisations.controller.ts +37 -0
- package/template/src/modules/organisations/types/default-organisation-data.ts +18 -0
- package/template/src/modules/platform-admin/.gitkeep +1 -0
- package/template/src/modules/request-context/application/services/request-context.service.ts +79 -0
- package/template/src/modules/request-context/presentation/org-scope.guard.ts +26 -0
- package/template/src/modules/request-context/presentation/request-context.interceptor.ts +26 -0
- package/template/src/modules/request-context/presentation/request-context.middleware.ts +31 -0
- package/template/src/modules/request-context/request-context.module.ts +25 -0
- package/template/src/modules/request-context/types/request-context.ts +29 -0
- package/template/src/modules/sample/application/services/sample.service.ts +67 -0
- package/template/src/modules/sample/dto/sample-echo.dto.ts +10 -0
- package/template/src/modules/sample/dto/sample-response.dto.ts +41 -0
- package/template/src/modules/sample/presentation/sample.controller.ts +63 -0
- package/template/src/modules/sample/sample.module.ts +11 -0
- package/template/src/modules/settings/application/services/settings.service.ts +139 -0
- package/template/src/modules/settings/dto/setting-response.dto.ts +27 -0
- package/template/src/modules/settings/dto/update-setting.dto.ts +16 -0
- package/template/src/modules/settings/presentation/settings.controller.ts +66 -0
- package/template/src/modules/settings/settings.module.ts +12 -0
- package/template/src/modules/settings/types/setting-definitions.ts +104 -0
- package/template/src/modules/users/.gitkeep +1 -0
- package/template/test/.gitkeep +1 -0
- package/template/test/jest-e2e.json +9 -0
- package/template/test/permission.guard.spec.ts +22 -0
- package/template/test/route-registry.validator.spec.ts +90 -0
- package/template/test/security.e2e-spec.ts +102 -0
- package/template/tsconfig.build.json +4 -0
- package/template/tsconfig.json +18 -0
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
|
|
9
|
+
type TokenUser = {
|
|
10
|
+
id: string;
|
|
11
|
+
email: string | null;
|
|
12
|
+
mobile: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type RefreshTokenStore = Prisma.TransactionClient | PrismaService;
|
|
16
|
+
|
|
17
|
+
@Injectable()
|
|
18
|
+
export class TokenService {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly config: ConfigService,
|
|
21
|
+
private readonly jwt: JwtService,
|
|
22
|
+
private readonly prisma: PrismaService,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
async signAccessToken(user: TokenUser) {
|
|
26
|
+
const payload: JwtPayload = {
|
|
27
|
+
sub: user.id,
|
|
28
|
+
email: user.email,
|
|
29
|
+
mobile: user.mobile,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return this.jwt.signAsync(payload, {
|
|
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"),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async createRefreshToken(
|
|
41
|
+
userId: string,
|
|
42
|
+
client: RefreshTokenStore = this.prisma,
|
|
43
|
+
) {
|
|
44
|
+
const refreshToken = randomBytes(48).toString("base64url");
|
|
45
|
+
const tokenHash = this.hashRefreshToken(refreshToken);
|
|
46
|
+
const expiresAt = this.getRefreshTokenExpiry();
|
|
47
|
+
|
|
48
|
+
const record = await client.refreshToken.create({
|
|
49
|
+
data: {
|
|
50
|
+
userId,
|
|
51
|
+
tokenHash,
|
|
52
|
+
expiresAt,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
refreshToken,
|
|
58
|
+
record,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
hashRefreshToken(refreshToken: string) {
|
|
63
|
+
return createHash("sha256").update(refreshToken).digest("hex");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getAccessTokenTtl() {
|
|
67
|
+
return this.config.get<string>("auth.jwt.accessExpiresIn") ?? "15m";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private getRefreshTokenExpiry() {
|
|
71
|
+
const ttl = this.config.get<string>("auth.jwt.refreshExpiresIn") ?? "7d";
|
|
72
|
+
return new Date(Date.now() + parseDurationToMs(ttl));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseDurationToMs(value: string) {
|
|
77
|
+
const match = /^(\d+)([smhdw])$/.exec(value.trim());
|
|
78
|
+
if (!match) {
|
|
79
|
+
throw new InternalServerErrorException(
|
|
80
|
+
`Unsupported duration value: ${value}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const amount = Number(match[1]);
|
|
85
|
+
const unit = match[2];
|
|
86
|
+
const multipliers: Record<string, number> = {
|
|
87
|
+
s: 1000,
|
|
88
|
+
m: 60 * 1000,
|
|
89
|
+
h: 60 * 60 * 1000,
|
|
90
|
+
d: 24 * 60 * 60 * 1000,
|
|
91
|
+
w: 7 * 24 * 60 * 60 * 1000,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return amount * multipliers[unit];
|
|
95
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
|
|
2
|
+
import { ConfigModule, ConfigService } from "@nestjs/config";
|
|
3
|
+
import { JwtModule } from "@nestjs/jwt";
|
|
4
|
+
import { PassportModule } from "@nestjs/passport";
|
|
5
|
+
import session = require("express-session");
|
|
6
|
+
import { AuthService } from "./application/services/auth.service";
|
|
7
|
+
import { PasswordService } from "./application/services/password.service";
|
|
8
|
+
import { TokenService } from "./application/services/token.service";
|
|
9
|
+
import { GoogleAuthGuard } from "./infrastructure/passport/google-auth.guard";
|
|
10
|
+
import { GoogleStrategy } from "./infrastructure/passport/google.strategy";
|
|
11
|
+
import { JwtAuthGuard } from "./infrastructure/passport/jwt-auth.guard";
|
|
12
|
+
import { JwtStrategy } from "./infrastructure/passport/jwt.strategy";
|
|
13
|
+
import { AuthController } from "./presentation/auth.controller";
|
|
14
|
+
import { GoogleOAuthExceptionFilter } from "./presentation/google-oauth-exception.filter";
|
|
15
|
+
|
|
16
|
+
@Module({
|
|
17
|
+
imports: [
|
|
18
|
+
PassportModule,
|
|
19
|
+
JwtModule.registerAsync({
|
|
20
|
+
imports: [ConfigModule],
|
|
21
|
+
inject: [ConfigService],
|
|
22
|
+
useFactory: (config: ConfigService) => ({
|
|
23
|
+
secret: config.get<string>("auth.jwt.secret") ?? "",
|
|
24
|
+
signOptions: {
|
|
25
|
+
algorithm: "HS256",
|
|
26
|
+
issuer: config.get<string>("auth.jwt.issuer"),
|
|
27
|
+
audience: config.get<string>("auth.jwt.audience"),
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
}),
|
|
31
|
+
],
|
|
32
|
+
controllers: [AuthController],
|
|
33
|
+
providers: [
|
|
34
|
+
AuthService,
|
|
35
|
+
GoogleAuthGuard,
|
|
36
|
+
GoogleOAuthExceptionFilter,
|
|
37
|
+
GoogleStrategy,
|
|
38
|
+
JwtAuthGuard,
|
|
39
|
+
JwtStrategy,
|
|
40
|
+
PasswordService,
|
|
41
|
+
TokenService,
|
|
42
|
+
],
|
|
43
|
+
exports: [JwtAuthGuard, PasswordService],
|
|
44
|
+
})
|
|
45
|
+
export class AuthModule implements NestModule {
|
|
46
|
+
constructor(private readonly config: ConfigService) {}
|
|
47
|
+
|
|
48
|
+
configure(consumer: MiddlewareConsumer) {
|
|
49
|
+
consumer
|
|
50
|
+
.apply(buildGoogleOAuthSessionMiddleware(this.config))
|
|
51
|
+
.forRoutes("auth/google", "auth/google/callback");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildGoogleOAuthSessionMiddleware(config: ConfigService) {
|
|
56
|
+
const secret =
|
|
57
|
+
config.get<string>("auth.session.secret") ||
|
|
58
|
+
"google-oauth-disabled-session-secret";
|
|
59
|
+
|
|
60
|
+
return session({
|
|
61
|
+
secret,
|
|
62
|
+
name: "oauth.sid",
|
|
63
|
+
resave: false,
|
|
64
|
+
saveUninitialized: false,
|
|
65
|
+
cookie: {
|
|
66
|
+
httpOnly: true,
|
|
67
|
+
sameSite: "lax",
|
|
68
|
+
secure: config.get<string>("app.nodeEnv") === "production",
|
|
69
|
+
maxAge: 10 * 60 * 1000,
|
|
70
|
+
path: "/auth/google",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ApiProperty } from "@nestjs/swagger";
|
|
2
|
+
import { ActiveUserSummaryDto } from "../../../common/dto/user-summary.dto";
|
|
3
|
+
|
|
4
|
+
export class AuthTokensResponseDto {
|
|
5
|
+
@ApiProperty({
|
|
6
|
+
description: "JWT access token for bearer authentication.",
|
|
7
|
+
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
|
8
|
+
})
|
|
9
|
+
accessToken!: string;
|
|
10
|
+
|
|
11
|
+
@ApiProperty({
|
|
12
|
+
description:
|
|
13
|
+
"Opaque refresh token. Store it securely and send it only to auth endpoints.",
|
|
14
|
+
example: "rfr_9b2f4f8d2d9f4d65a1f4",
|
|
15
|
+
})
|
|
16
|
+
refreshToken!: string;
|
|
17
|
+
|
|
18
|
+
@ApiProperty({ example: "Bearer" })
|
|
19
|
+
tokenType!: "Bearer";
|
|
20
|
+
|
|
21
|
+
@ApiProperty({
|
|
22
|
+
description: "Access token lifetime in seconds.",
|
|
23
|
+
example: 900,
|
|
24
|
+
})
|
|
25
|
+
expiresIn!: number;
|
|
26
|
+
|
|
27
|
+
@ApiProperty({ type: ActiveUserSummaryDto })
|
|
28
|
+
user!: ActiveUserSummaryDto;
|
|
29
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ApiProperty } from "@nestjs/swagger";
|
|
2
|
+
import { IsEmail, IsString, MaxLength, MinLength } from "class-validator";
|
|
3
|
+
|
|
4
|
+
export class LoginDto {
|
|
5
|
+
@ApiProperty({ example: "owner@example.com" })
|
|
6
|
+
@IsEmail()
|
|
7
|
+
@MaxLength(320)
|
|
8
|
+
email!: string;
|
|
9
|
+
|
|
10
|
+
@ApiProperty({ example: "dev-password-123" })
|
|
11
|
+
@IsString()
|
|
12
|
+
@MinLength(8)
|
|
13
|
+
@MaxLength(128)
|
|
14
|
+
password!: string;
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ApiProperty } from "@nestjs/swagger";
|
|
2
|
+
import { IsString, Length } from "class-validator";
|
|
3
|
+
|
|
4
|
+
export class OAuthExchangeDto {
|
|
5
|
+
@ApiProperty({
|
|
6
|
+
description:
|
|
7
|
+
"One-time code returned to the frontend by the Google OAuth callback redirect.",
|
|
8
|
+
example: "DXB2rcx5HcKXy35bA3AzYdVq2e4nGsgYeG3D9M7uAQM",
|
|
9
|
+
minLength: 32,
|
|
10
|
+
maxLength: 256,
|
|
11
|
+
})
|
|
12
|
+
@IsString()
|
|
13
|
+
@Length(32, 256)
|
|
14
|
+
code!: string;
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ApiProperty } from "@nestjs/swagger";
|
|
2
|
+
import { IsString, MinLength } from "class-validator";
|
|
3
|
+
|
|
4
|
+
export class RefreshTokenDto {
|
|
5
|
+
@ApiProperty({
|
|
6
|
+
description:
|
|
7
|
+
"Opaque refresh token returned by signup, login, refresh, or OAuth exchange.",
|
|
8
|
+
example: "rfr_9b2f4f8d2d9f4d65a1f4",
|
|
9
|
+
minLength: 32,
|
|
10
|
+
})
|
|
11
|
+
@IsString()
|
|
12
|
+
@MinLength(32)
|
|
13
|
+
refreshToken!: string;
|
|
14
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
|
2
|
+
import {
|
|
3
|
+
IsEmail,
|
|
4
|
+
IsOptional,
|
|
5
|
+
IsString,
|
|
6
|
+
MaxLength,
|
|
7
|
+
MinLength,
|
|
8
|
+
} from "class-validator";
|
|
9
|
+
|
|
10
|
+
export class SignupDto {
|
|
11
|
+
@ApiProperty({ example: "owner@example.com" })
|
|
12
|
+
@IsEmail()
|
|
13
|
+
@MaxLength(320)
|
|
14
|
+
email!: string;
|
|
15
|
+
|
|
16
|
+
@ApiProperty({ minLength: 8, maxLength: 128, example: "dev-password-123" })
|
|
17
|
+
@IsString()
|
|
18
|
+
@MinLength(8)
|
|
19
|
+
@MaxLength(128)
|
|
20
|
+
password!: string;
|
|
21
|
+
|
|
22
|
+
@ApiPropertyOptional({ example: "Starter Owner" })
|
|
23
|
+
@IsOptional()
|
|
24
|
+
@IsString()
|
|
25
|
+
@MaxLength(120)
|
|
26
|
+
displayName?: string;
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CanActivate,
|
|
3
|
+
ExecutionContext,
|
|
4
|
+
ForbiddenException,
|
|
5
|
+
Injectable,
|
|
6
|
+
} from "@nestjs/common";
|
|
7
|
+
import { ConfigService } from "@nestjs/config";
|
|
8
|
+
import { AuthGuard } from "@nestjs/passport";
|
|
9
|
+
|
|
10
|
+
@Injectable()
|
|
11
|
+
export class GoogleAuthGuard
|
|
12
|
+
extends AuthGuard("google")
|
|
13
|
+
implements CanActivate
|
|
14
|
+
{
|
|
15
|
+
constructor(private readonly config: ConfigService) {
|
|
16
|
+
super();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
canActivate(context: ExecutionContext) {
|
|
20
|
+
const enabled = this.config.get<boolean>("auth.providers.googleEnabled");
|
|
21
|
+
if (!enabled) {
|
|
22
|
+
throw new ForbiddenException("Google authentication is disabled.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return super.canActivate(context);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
|
2
|
+
import { ConfigService } from "@nestjs/config";
|
|
3
|
+
import { PassportStrategy } from "@nestjs/passport";
|
|
4
|
+
import { Profile, Strategy, VerifyCallback } from "passport-google-oauth20";
|
|
5
|
+
import { GoogleAuthProfile } from "../../types/google-auth-profile";
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
|
|
9
|
+
constructor(config: ConfigService) {
|
|
10
|
+
const enabled = config.get<boolean>("auth.providers.googleEnabled");
|
|
11
|
+
|
|
12
|
+
super({
|
|
13
|
+
clientID: enabled
|
|
14
|
+
? (config.get<string>("auth.google.clientId") ?? "")
|
|
15
|
+
: "google-disabled",
|
|
16
|
+
clientSecret: enabled
|
|
17
|
+
? (config.get<string>("auth.google.clientSecret") ?? "")
|
|
18
|
+
: "google-disabled",
|
|
19
|
+
callbackURL:
|
|
20
|
+
config.get<string>("auth.google.callbackUrl") ??
|
|
21
|
+
"http://localhost:3000/auth/google/callback",
|
|
22
|
+
state: true,
|
|
23
|
+
pkce: true,
|
|
24
|
+
scope: ["email", "profile"],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
validate(
|
|
29
|
+
_accessToken: string,
|
|
30
|
+
_refreshToken: string,
|
|
31
|
+
profile: Profile,
|
|
32
|
+
done: VerifyCallback,
|
|
33
|
+
) {
|
|
34
|
+
const email = profile.emails?.[0]?.value?.trim().toLowerCase();
|
|
35
|
+
if (!email) {
|
|
36
|
+
done(
|
|
37
|
+
new UnauthorizedException(
|
|
38
|
+
"Google account did not provide an email address.",
|
|
39
|
+
),
|
|
40
|
+
);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const profileJson = profile._json as
|
|
45
|
+
| { email_verified?: boolean }
|
|
46
|
+
| undefined;
|
|
47
|
+
const result: GoogleAuthProfile = {
|
|
48
|
+
providerUserId: profile.id,
|
|
49
|
+
email,
|
|
50
|
+
emailVerified: profileJson?.email_verified === true,
|
|
51
|
+
displayName: profile.displayName || null,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
done(null, result);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
|
2
|
+
import { ConfigService } from "@nestjs/config";
|
|
3
|
+
import { PassportStrategy } from "@nestjs/passport";
|
|
4
|
+
import { ExtractJwt, Strategy } from "passport-jwt";
|
|
5
|
+
import { PrismaService } from "../../../../database/prisma/prisma.service";
|
|
6
|
+
import { AuthenticatedUser } from "../../types/authenticated-user";
|
|
7
|
+
import { JwtPayload } from "../../types/jwt-payload";
|
|
8
|
+
|
|
9
|
+
@Injectable()
|
|
10
|
+
export class JwtStrategy extends PassportStrategy(Strategy) {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly prisma: PrismaService,
|
|
13
|
+
config: ConfigService,
|
|
14
|
+
) {
|
|
15
|
+
super({
|
|
16
|
+
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
17
|
+
ignoreExpiration: false,
|
|
18
|
+
algorithms: ["HS256"],
|
|
19
|
+
issuer: config.get<string>("auth.jwt.issuer"),
|
|
20
|
+
audience: config.get<string>("auth.jwt.audience"),
|
|
21
|
+
secretOrKey: config.get<string>("auth.jwt.secret") ?? "",
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async validate(payload: JwtPayload): Promise<AuthenticatedUser> {
|
|
26
|
+
const user = await this.prisma.user.findUnique({
|
|
27
|
+
where: { id: payload.sub },
|
|
28
|
+
select: {
|
|
29
|
+
id: true,
|
|
30
|
+
email: true,
|
|
31
|
+
mobile: true,
|
|
32
|
+
displayName: true,
|
|
33
|
+
isActive: true,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (!user?.isActive) {
|
|
38
|
+
throw new UnauthorizedException();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return user;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Controller,
|
|
4
|
+
Get,
|
|
5
|
+
HttpCode,
|
|
6
|
+
Post,
|
|
7
|
+
Req,
|
|
8
|
+
Res,
|
|
9
|
+
UseFilters,
|
|
10
|
+
UseGuards,
|
|
11
|
+
} from "@nestjs/common";
|
|
12
|
+
import {
|
|
13
|
+
ApiBearerAuth,
|
|
14
|
+
ApiCreatedResponse,
|
|
15
|
+
ApiFoundResponse,
|
|
16
|
+
ApiOkResponse,
|
|
17
|
+
ApiOperation,
|
|
18
|
+
ApiTags,
|
|
19
|
+
} from "@nestjs/swagger";
|
|
20
|
+
import { Throttle } from "@nestjs/throttler";
|
|
21
|
+
import { RevokedResponseDto } from "../../../common/dto/mutation-response.dto";
|
|
22
|
+
import { ActiveUserSummaryDto } from "../../../common/dto/user-summary.dto";
|
|
23
|
+
import { ApiErrorResponses } from "../../../common/swagger/api-error-responses";
|
|
24
|
+
import { AuthService } from "../application/services/auth.service";
|
|
25
|
+
import { AuthTokensResponseDto } from "../dto/auth-response.dto";
|
|
26
|
+
import { LoginDto } from "../dto/login.dto";
|
|
27
|
+
import { LogoutDto } from "../dto/logout.dto";
|
|
28
|
+
import { OAuthExchangeDto } from "../dto/oauth-exchange.dto";
|
|
29
|
+
import { RefreshTokenDto } from "../dto/refresh-token.dto";
|
|
30
|
+
import { SignupDto } from "../dto/signup.dto";
|
|
31
|
+
import { GoogleAuthGuard } from "../infrastructure/passport/google-auth.guard";
|
|
32
|
+
import { JwtAuthGuard } from "../infrastructure/passport/jwt-auth.guard";
|
|
33
|
+
import { AuthenticatedUser } from "../types/authenticated-user";
|
|
34
|
+
import { GoogleAuthProfile } from "../types/google-auth-profile";
|
|
35
|
+
import { CurrentUser } from "./current-user.decorator";
|
|
36
|
+
import { GoogleOAuthExceptionFilter } from "./google-oauth-exception.filter";
|
|
37
|
+
|
|
38
|
+
@ApiTags("Auth")
|
|
39
|
+
@Controller("auth")
|
|
40
|
+
export class AuthController {
|
|
41
|
+
constructor(private readonly authService: AuthService) {}
|
|
42
|
+
|
|
43
|
+
@Post("signup")
|
|
44
|
+
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
|
45
|
+
@ApiOperation({ summary: "Create an email/password user and issue tokens." })
|
|
46
|
+
@ApiCreatedResponse({
|
|
47
|
+
description: "User created and tokens issued.",
|
|
48
|
+
type: AuthTokensResponseDto,
|
|
49
|
+
})
|
|
50
|
+
@ApiErrorResponses(400, 409, 429)
|
|
51
|
+
signup(@Body() dto: SignupDto) {
|
|
52
|
+
return this.authService.signup(dto);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Post("login")
|
|
56
|
+
@HttpCode(200)
|
|
57
|
+
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
|
58
|
+
@ApiOperation({ summary: "Login with email/password." })
|
|
59
|
+
@ApiOkResponse({
|
|
60
|
+
description: "Login succeeded and tokens were issued.",
|
|
61
|
+
type: AuthTokensResponseDto,
|
|
62
|
+
})
|
|
63
|
+
@ApiErrorResponses(400, 401, 429)
|
|
64
|
+
login(@Body() dto: LoginDto) {
|
|
65
|
+
return this.authService.login(dto);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Post("refresh")
|
|
69
|
+
@HttpCode(200)
|
|
70
|
+
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
|
71
|
+
@ApiOperation({
|
|
72
|
+
summary: "Rotate a refresh token and issue a new access token.",
|
|
73
|
+
})
|
|
74
|
+
@ApiOkResponse({
|
|
75
|
+
description: "Refresh token rotated.",
|
|
76
|
+
type: AuthTokensResponseDto,
|
|
77
|
+
})
|
|
78
|
+
@ApiErrorResponses(400, 401, 429)
|
|
79
|
+
refresh(@Body() dto: RefreshTokenDto) {
|
|
80
|
+
return this.authService.refresh(dto);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@Post("oauth/exchange")
|
|
84
|
+
@HttpCode(200)
|
|
85
|
+
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
|
86
|
+
@ApiOperation({
|
|
87
|
+
summary: "Exchange a Google OAuth one-time code for tokens.",
|
|
88
|
+
})
|
|
89
|
+
@ApiOkResponse({
|
|
90
|
+
description: "OAuth exchange succeeded.",
|
|
91
|
+
type: AuthTokensResponseDto,
|
|
92
|
+
})
|
|
93
|
+
@ApiErrorResponses(400, 401, 429)
|
|
94
|
+
exchangeOAuthCode(@Body() dto: OAuthExchangeDto) {
|
|
95
|
+
return this.authService.exchangeOAuthCode(dto);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@Post("logout")
|
|
99
|
+
@HttpCode(200)
|
|
100
|
+
@ApiOperation({ summary: "Revoke a refresh token." })
|
|
101
|
+
@ApiOkResponse({
|
|
102
|
+
description: "Refresh token is revoked or already unusable.",
|
|
103
|
+
type: RevokedResponseDto,
|
|
104
|
+
})
|
|
105
|
+
@ApiErrorResponses(400)
|
|
106
|
+
logout(@Body() dto: LogoutDto) {
|
|
107
|
+
return this.authService.logout(dto);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@Get("google")
|
|
111
|
+
@UseFilters(GoogleOAuthExceptionFilter)
|
|
112
|
+
@UseGuards(GoogleAuthGuard)
|
|
113
|
+
@ApiOperation({ summary: "Start the Google OAuth login flow." })
|
|
114
|
+
@ApiFoundResponse({ description: "Redirects the browser to Google OAuth." })
|
|
115
|
+
@ApiErrorResponses(403)
|
|
116
|
+
google() {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@Get("google/callback")
|
|
121
|
+
@UseFilters(GoogleOAuthExceptionFilter)
|
|
122
|
+
@UseGuards(GoogleAuthGuard)
|
|
123
|
+
@ApiOperation({ summary: "Handle the Google OAuth callback." })
|
|
124
|
+
@ApiFoundResponse({
|
|
125
|
+
description: "Redirects to the configured frontend success or error URL.",
|
|
126
|
+
})
|
|
127
|
+
@ApiErrorResponses(401, 403)
|
|
128
|
+
async googleCallback(
|
|
129
|
+
@Req() request: { user: GoogleAuthProfile },
|
|
130
|
+
@Res() response: { redirect: (status: number, url: string) => void },
|
|
131
|
+
) {
|
|
132
|
+
const redirectUrl = await this.authService.googleLogin(request.user);
|
|
133
|
+
response.redirect(302, redirectUrl);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@Get("me")
|
|
137
|
+
@UseGuards(JwtAuthGuard)
|
|
138
|
+
@ApiBearerAuth()
|
|
139
|
+
@ApiOperation({ summary: "Return the current JWT identity." })
|
|
140
|
+
@ApiOkResponse({
|
|
141
|
+
description: "Current authenticated user.",
|
|
142
|
+
type: ActiveUserSummaryDto,
|
|
143
|
+
})
|
|
144
|
+
@ApiErrorResponses(401)
|
|
145
|
+
me(@CurrentUser() user: AuthenticatedUser) {
|
|
146
|
+
return this.authService.me(user);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
|
2
|
+
import { AuthenticatedUser } from "../types/authenticated-user";
|
|
3
|
+
|
|
4
|
+
export const CurrentUser = createParamDecorator(
|
|
5
|
+
(_data: unknown, context: ExecutionContext): AuthenticatedUser => {
|
|
6
|
+
const request = context
|
|
7
|
+
.switchToHttp()
|
|
8
|
+
.getRequest<{ user: AuthenticatedUser }>();
|
|
9
|
+
return request.user;
|
|
10
|
+
},
|
|
11
|
+
);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArgumentsHost,
|
|
3
|
+
Catch,
|
|
4
|
+
ExceptionFilter,
|
|
5
|
+
Injectable,
|
|
6
|
+
} from "@nestjs/common";
|
|
7
|
+
import { ConfigService } from "@nestjs/config";
|
|
8
|
+
|
|
9
|
+
type RedirectResponse = {
|
|
10
|
+
headersSent?: boolean;
|
|
11
|
+
redirect: (status: number, url: string) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
@Injectable()
|
|
15
|
+
@Catch()
|
|
16
|
+
export class GoogleOAuthExceptionFilter implements ExceptionFilter {
|
|
17
|
+
constructor(private readonly config: ConfigService) {}
|
|
18
|
+
|
|
19
|
+
catch(_exception: unknown, host: ArgumentsHost) {
|
|
20
|
+
const response = host.switchToHttp().getResponse<RedirectResponse>();
|
|
21
|
+
if (response.headersSent) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const redirectUrl = new URL(
|
|
26
|
+
this.config.get<string>("auth.google.errorRedirectUrl") ??
|
|
27
|
+
"http://localhost:3000/auth/google/error",
|
|
28
|
+
);
|
|
29
|
+
redirectUrl.searchParams.set("error", "oauth_failed");
|
|
30
|
+
|
|
31
|
+
response.redirect(302, redirectUrl.toString());
|
|
32
|
+
}
|
|
33
|
+
}
|