create-forgeon 0.1.35 → 0.1.37
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/README.md +1 -0
- package/package.json +1 -1
- package/src/modules/executor.mjs +2 -0
- package/src/modules/executor.test.mjs +172 -8
- package/src/modules/jwt-auth.mjs +612 -0
- package/src/modules/registry.mjs +7 -7
- package/templates/module-fragments/jwt-auth/20_scope.md +17 -7
- package/templates/module-fragments/jwt-auth/90_status_implemented.md +7 -0
- package/templates/module-presets/jwt-auth/apps/api/prisma/migrations/0002_auth_refresh_token_hash/migration.sql +3 -0
- package/templates/module-presets/jwt-auth/apps/api/src/auth/prisma-auth-refresh-token.store.ts +36 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/package.json +28 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-config.loader.ts +27 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-config.module.ts +8 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-config.service.ts +36 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-env.schema.ts +19 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-refresh-token.store.ts +23 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.controller.ts +71 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.service.ts +166 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.types.ts +6 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/index.ts +2 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/login.dto.ts +11 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/refresh.dto.ts +8 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/forgeon-auth.module.ts +47 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/index.ts +12 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/jwt-auth.guard.ts +5 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/src/jwt.strategy.ts +20 -0
- package/templates/module-presets/jwt-auth/packages/auth-api/tsconfig.json +9 -0
- package/templates/module-presets/jwt-auth/packages/auth-contracts/package.json +21 -0
- package/templates/module-presets/jwt-auth/packages/auth-contracts/src/index.ts +47 -0
- package/templates/module-presets/jwt-auth/packages/auth-contracts/tsconfig.json +9 -0
package/templates/module-presets/jwt-auth/apps/api/src/auth/prisma-auth-refresh-token.store.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthRefreshTokenStore,
|
|
3
|
+
} from '@forgeon/auth-api';
|
|
4
|
+
import { PrismaService } from '@forgeon/db-prisma';
|
|
5
|
+
import { Injectable } from '@nestjs/common';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class PrismaAuthRefreshTokenStore implements AuthRefreshTokenStore {
|
|
9
|
+
readonly kind = 'prisma';
|
|
10
|
+
|
|
11
|
+
constructor(private readonly prisma: PrismaService) {}
|
|
12
|
+
|
|
13
|
+
async saveRefreshTokenHash(subject: string, hash: string): Promise<void> {
|
|
14
|
+
await this.prisma.user.upsert({
|
|
15
|
+
where: { email: subject },
|
|
16
|
+
create: { email: subject, refreshTokenHash: hash },
|
|
17
|
+
update: { refreshTokenHash: hash },
|
|
18
|
+
select: { id: true },
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async getRefreshTokenHash(subject: string): Promise<string | null> {
|
|
23
|
+
const user = await this.prisma.user.findUnique({
|
|
24
|
+
where: { email: subject },
|
|
25
|
+
select: { refreshTokenHash: true },
|
|
26
|
+
});
|
|
27
|
+
return user?.refreshTokenHash ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async removeRefreshTokenHash(subject: string): Promise<void> {
|
|
31
|
+
await this.prisma.user.updateMany({
|
|
32
|
+
where: { email: subject },
|
|
33
|
+
data: { refreshTokenHash: null },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeon/auth-api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -p tsconfig.json"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@forgeon/auth-contracts": "workspace:*",
|
|
12
|
+
"@nestjs/common": "^11.0.1",
|
|
13
|
+
"@nestjs/config": "^4.0.2",
|
|
14
|
+
"@nestjs/jwt": "^11.0.1",
|
|
15
|
+
"@nestjs/passport": "^11.0.5",
|
|
16
|
+
"bcryptjs": "^2.4.3",
|
|
17
|
+
"class-validator": "^0.14.1",
|
|
18
|
+
"passport": "^0.7.0",
|
|
19
|
+
"passport-jwt": "^4.0.1",
|
|
20
|
+
"zod": "^3.23.8"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/bcryptjs": "^2.4.6",
|
|
24
|
+
"@types/node": "^22.10.7",
|
|
25
|
+
"@types/passport-jwt": "^4.0.1",
|
|
26
|
+
"typescript": "^5.7.3"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { registerAs } from '@nestjs/config';
|
|
2
|
+
import { parseAuthEnv } from './auth-env.schema';
|
|
3
|
+
|
|
4
|
+
export const AUTH_CONFIG_NAMESPACE = 'auth';
|
|
5
|
+
|
|
6
|
+
export interface AuthConfigValues {
|
|
7
|
+
accessSecret: string;
|
|
8
|
+
accessExpiresIn: string;
|
|
9
|
+
refreshSecret: string;
|
|
10
|
+
refreshExpiresIn: string;
|
|
11
|
+
bcryptRounds: number;
|
|
12
|
+
demoEmail: string;
|
|
13
|
+
demoPassword: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const authConfig = registerAs(AUTH_CONFIG_NAMESPACE, (): AuthConfigValues => {
|
|
17
|
+
const env = parseAuthEnv(process.env);
|
|
18
|
+
return {
|
|
19
|
+
accessSecret: env.JWT_ACCESS_SECRET,
|
|
20
|
+
accessExpiresIn: env.JWT_ACCESS_EXPIRES_IN,
|
|
21
|
+
refreshSecret: env.JWT_REFRESH_SECRET,
|
|
22
|
+
refreshExpiresIn: env.JWT_REFRESH_EXPIRES_IN,
|
|
23
|
+
bcryptRounds: env.AUTH_BCRYPT_ROUNDS,
|
|
24
|
+
demoEmail: env.AUTH_DEMO_EMAIL.toLowerCase(),
|
|
25
|
+
demoPassword: env.AUTH_DEMO_PASSWORD,
|
|
26
|
+
};
|
|
27
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import { AUTH_CONFIG_NAMESPACE } from './auth-config.loader';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class AuthConfigService {
|
|
7
|
+
constructor(private readonly configService: ConfigService) {}
|
|
8
|
+
|
|
9
|
+
get accessSecret(): string {
|
|
10
|
+
return this.configService.getOrThrow<string>(`${AUTH_CONFIG_NAMESPACE}.accessSecret`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get accessExpiresIn(): string {
|
|
14
|
+
return this.configService.getOrThrow<string>(`${AUTH_CONFIG_NAMESPACE}.accessExpiresIn`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get refreshSecret(): string {
|
|
18
|
+
return this.configService.getOrThrow<string>(`${AUTH_CONFIG_NAMESPACE}.refreshSecret`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get refreshExpiresIn(): string {
|
|
22
|
+
return this.configService.getOrThrow<string>(`${AUTH_CONFIG_NAMESPACE}.refreshExpiresIn`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get bcryptRounds(): number {
|
|
26
|
+
return this.configService.getOrThrow<number>(`${AUTH_CONFIG_NAMESPACE}.bcryptRounds`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get demoEmail(): string {
|
|
30
|
+
return this.configService.getOrThrow<string>(`${AUTH_CONFIG_NAMESPACE}.demoEmail`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get demoPassword(): string {
|
|
34
|
+
return this.configService.getOrThrow<string>(`${AUTH_CONFIG_NAMESPACE}.demoPassword`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const authEnvSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
JWT_ACCESS_SECRET: z.string().trim().min(16).default('forgeon-access-secret-change-me'),
|
|
6
|
+
JWT_ACCESS_EXPIRES_IN: z.string().trim().min(2).default('15m'),
|
|
7
|
+
JWT_REFRESH_SECRET: z.string().trim().min(16).default('forgeon-refresh-secret-change-me'),
|
|
8
|
+
JWT_REFRESH_EXPIRES_IN: z.string().trim().min(2).default('7d'),
|
|
9
|
+
AUTH_BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
|
|
10
|
+
AUTH_DEMO_EMAIL: z.string().trim().email().default('demo@forgeon.local'),
|
|
11
|
+
AUTH_DEMO_PASSWORD: z.string().min(8).default('forgeon-demo-password'),
|
|
12
|
+
})
|
|
13
|
+
.passthrough();
|
|
14
|
+
|
|
15
|
+
export type AuthEnv = z.infer<typeof authEnvSchema>;
|
|
16
|
+
|
|
17
|
+
export function parseAuthEnv(input: Record<string, unknown>): AuthEnv {
|
|
18
|
+
return authEnvSchema.parse(input);
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
export const AUTH_REFRESH_TOKEN_STORE = Symbol('AUTH_REFRESH_TOKEN_STORE');
|
|
4
|
+
|
|
5
|
+
export interface AuthRefreshTokenStore {
|
|
6
|
+
readonly kind: string;
|
|
7
|
+
saveRefreshTokenHash(subject: string, hash: string): Promise<void>;
|
|
8
|
+
getRefreshTokenHash(subject: string): Promise<string | null>;
|
|
9
|
+
removeRefreshTokenHash(subject: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class NoopAuthRefreshTokenStore implements AuthRefreshTokenStore {
|
|
14
|
+
readonly kind = 'none';
|
|
15
|
+
|
|
16
|
+
async saveRefreshTokenHash(): Promise<void> {}
|
|
17
|
+
|
|
18
|
+
async getRefreshTokenHash(): Promise<string | null> {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async removeRefreshTokenHash(): Promise<void> {}
|
|
23
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { AuthUser } from '@forgeon/auth-contracts';
|
|
2
|
+
import {
|
|
3
|
+
Body,
|
|
4
|
+
Controller,
|
|
5
|
+
Get,
|
|
6
|
+
Post,
|
|
7
|
+
Req,
|
|
8
|
+
UseGuards,
|
|
9
|
+
} from '@nestjs/common';
|
|
10
|
+
import { AuthService } from './auth.service';
|
|
11
|
+
import { LoginDto, RefreshDto } from './dto';
|
|
12
|
+
import { JwtAuthGuard } from './jwt-auth.guard';
|
|
13
|
+
import { AuthJwtPayload } from './auth.types';
|
|
14
|
+
|
|
15
|
+
type RequestWithUser = { user?: AuthJwtPayload };
|
|
16
|
+
|
|
17
|
+
@Controller('auth')
|
|
18
|
+
export class AuthController {
|
|
19
|
+
constructor(private readonly authService: AuthService) {}
|
|
20
|
+
|
|
21
|
+
@Post('login')
|
|
22
|
+
login(@Body() body: LoginDto) {
|
|
23
|
+
return this.authService.login(body);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Post('refresh')
|
|
27
|
+
refresh(@Body() body: RefreshDto) {
|
|
28
|
+
return this.authService.refresh(body);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@UseGuards(JwtAuthGuard)
|
|
32
|
+
@Post('logout')
|
|
33
|
+
async logout(@Req() request: RequestWithUser) {
|
|
34
|
+
const user = this.getRequestUser(request);
|
|
35
|
+
await this.authService.logout(user);
|
|
36
|
+
return {
|
|
37
|
+
status: 'ok',
|
|
38
|
+
tokenStore: this.authService.getTokenStoreKind(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@UseGuards(JwtAuthGuard)
|
|
43
|
+
@Get('me')
|
|
44
|
+
me(@Req() request: RequestWithUser) {
|
|
45
|
+
const user = this.getRequestUser(request);
|
|
46
|
+
return {
|
|
47
|
+
user: this.toAuthUser(user),
|
|
48
|
+
tokenStore: this.authService.getTokenStoreKind(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private getRequestUser(request: RequestWithUser): AuthJwtPayload {
|
|
53
|
+
const user = request.user;
|
|
54
|
+
if (!user || typeof user.sub !== 'string' || typeof user.email !== 'string') {
|
|
55
|
+
return {
|
|
56
|
+
sub: 'unknown',
|
|
57
|
+
email: 'unknown@invalid.local',
|
|
58
|
+
roles: ['user'],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return user;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private toAuthUser(payload: AuthJwtPayload): AuthUser {
|
|
65
|
+
return {
|
|
66
|
+
sub: payload.sub,
|
|
67
|
+
email: payload.email,
|
|
68
|
+
roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AUTH_ERROR_CODES,
|
|
3
|
+
AuthUser,
|
|
4
|
+
LoginResponse,
|
|
5
|
+
RefreshResponse,
|
|
6
|
+
TokenPair,
|
|
7
|
+
} from '@forgeon/auth-contracts';
|
|
8
|
+
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
|
9
|
+
import { JwtService } from '@nestjs/jwt';
|
|
10
|
+
import type { JwtSignOptions } from '@nestjs/jwt';
|
|
11
|
+
import { compare, hash } from 'bcryptjs';
|
|
12
|
+
import {
|
|
13
|
+
AUTH_REFRESH_TOKEN_STORE,
|
|
14
|
+
AuthRefreshTokenStore,
|
|
15
|
+
} from './auth-refresh-token.store';
|
|
16
|
+
import { AuthConfigService } from './auth-config.service';
|
|
17
|
+
import { LoginDto, RefreshDto } from './dto';
|
|
18
|
+
import { AuthJwtPayload } from './auth.types';
|
|
19
|
+
|
|
20
|
+
type JwtExpiresIn = NonNullable<JwtSignOptions['expiresIn']>;
|
|
21
|
+
|
|
22
|
+
@Injectable()
|
|
23
|
+
export class AuthService {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly jwtService: JwtService,
|
|
26
|
+
private readonly configService: AuthConfigService,
|
|
27
|
+
@Inject(AUTH_REFRESH_TOKEN_STORE)
|
|
28
|
+
private readonly refreshTokenStore: AuthRefreshTokenStore,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
getTokenStoreKind(): string {
|
|
32
|
+
return this.refreshTokenStore.kind;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async login(input: LoginDto): Promise<LoginResponse> {
|
|
36
|
+
const email = input.email.trim().toLowerCase();
|
|
37
|
+
if (email !== this.configService.demoEmail || input.password !== this.configService.demoPassword) {
|
|
38
|
+
throw new UnauthorizedException({
|
|
39
|
+
message: 'Invalid credentials',
|
|
40
|
+
details: { code: AUTH_ERROR_CODES.invalidCredentials },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const user: AuthUser = {
|
|
45
|
+
sub: email,
|
|
46
|
+
email,
|
|
47
|
+
roles: ['user'],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const tokens = await this.issueTokens(user);
|
|
51
|
+
return {
|
|
52
|
+
...tokens,
|
|
53
|
+
user,
|
|
54
|
+
tokenStore: this.refreshTokenStore.kind,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async refresh(input: RefreshDto): Promise<RefreshResponse> {
|
|
59
|
+
let payload: AuthJwtPayload;
|
|
60
|
+
try {
|
|
61
|
+
payload = await this.jwtService.verifyAsync<AuthJwtPayload>(input.refreshToken, {
|
|
62
|
+
secret: this.configService.refreshSecret,
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
const code =
|
|
66
|
+
error instanceof Error && error.name === 'TokenExpiredError'
|
|
67
|
+
? AUTH_ERROR_CODES.tokenExpired
|
|
68
|
+
: AUTH_ERROR_CODES.refreshInvalid;
|
|
69
|
+
|
|
70
|
+
throw new UnauthorizedException({
|
|
71
|
+
message: 'Refresh token is invalid or expired',
|
|
72
|
+
details: { code },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (this.refreshTokenStore.kind !== 'none') {
|
|
77
|
+
const storedHash = await this.refreshTokenStore.getRefreshTokenHash(payload.sub);
|
|
78
|
+
if (!storedHash) {
|
|
79
|
+
throw new UnauthorizedException({
|
|
80
|
+
message: 'Refresh token is invalid or expired',
|
|
81
|
+
details: { code: AUTH_ERROR_CODES.refreshInvalid },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const matched = await compare(input.refreshToken, storedHash);
|
|
86
|
+
if (!matched) {
|
|
87
|
+
throw new UnauthorizedException({
|
|
88
|
+
message: 'Refresh token is invalid or expired',
|
|
89
|
+
details: { code: AUTH_ERROR_CODES.refreshInvalid },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const user = this.toAuthUser(payload);
|
|
95
|
+
const tokens = await this.issueTokens(user);
|
|
96
|
+
return {
|
|
97
|
+
...tokens,
|
|
98
|
+
user,
|
|
99
|
+
tokenStore: this.refreshTokenStore.kind,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async logout(user: AuthJwtPayload): Promise<void> {
|
|
104
|
+
if (this.refreshTokenStore.kind === 'none') {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await this.refreshTokenStore.removeRefreshTokenHash(user.sub);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
getProbeStatus() {
|
|
111
|
+
return {
|
|
112
|
+
status: 'ok',
|
|
113
|
+
feature: 'jwt-auth',
|
|
114
|
+
tokenStore: this.refreshTokenStore.kind,
|
|
115
|
+
demoEmail: this.configService.demoEmail,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async issueTokens(user: AuthUser): Promise<TokenPair> {
|
|
120
|
+
const payload: AuthJwtPayload = {
|
|
121
|
+
sub: user.sub,
|
|
122
|
+
email: user.email,
|
|
123
|
+
roles: user.roles,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const [accessToken, refreshToken] = await Promise.all([
|
|
127
|
+
this.jwtService.signAsync(payload, {
|
|
128
|
+
secret: this.configService.accessSecret,
|
|
129
|
+
expiresIn: this.toJwtExpiresIn(this.configService.accessExpiresIn),
|
|
130
|
+
}),
|
|
131
|
+
this.jwtService.signAsync(payload, {
|
|
132
|
+
secret: this.configService.refreshSecret,
|
|
133
|
+
expiresIn: this.toJwtExpiresIn(this.configService.refreshExpiresIn),
|
|
134
|
+
}),
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
if (this.refreshTokenStore.kind !== 'none') {
|
|
138
|
+
const tokenHash = await hash(refreshToken, this.configService.bcryptRounds);
|
|
139
|
+
await this.refreshTokenStore.saveRefreshTokenHash(user.sub, tokenHash);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
accessToken,
|
|
144
|
+
refreshToken,
|
|
145
|
+
tokenType: 'Bearer',
|
|
146
|
+
accessTtl: this.configService.accessExpiresIn,
|
|
147
|
+
refreshTtl: this.configService.refreshExpiresIn,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private toAuthUser(payload: AuthJwtPayload): AuthUser {
|
|
152
|
+
return {
|
|
153
|
+
sub: payload.sub,
|
|
154
|
+
email: payload.email,
|
|
155
|
+
roles: Array.isArray(payload.roles) ? payload.roles : ['user'],
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private toJwtExpiresIn(value: string): JwtExpiresIn {
|
|
160
|
+
const trimmed = value.trim();
|
|
161
|
+
if (/^\d+$/.test(trimmed)) {
|
|
162
|
+
return Number(trimmed);
|
|
163
|
+
}
|
|
164
|
+
return trimmed as JwtExpiresIn;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { LoginRequest } from '@forgeon/auth-contracts';
|
|
2
|
+
import { IsEmail, IsString, MinLength } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class LoginDto implements LoginRequest {
|
|
5
|
+
@IsEmail()
|
|
6
|
+
email!: string;
|
|
7
|
+
|
|
8
|
+
@IsString()
|
|
9
|
+
@MinLength(8)
|
|
10
|
+
password!: string;
|
|
11
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DynamicModule,
|
|
3
|
+
Module,
|
|
4
|
+
ModuleMetadata,
|
|
5
|
+
Provider,
|
|
6
|
+
} from '@nestjs/common';
|
|
7
|
+
import { JwtModule } from '@nestjs/jwt';
|
|
8
|
+
import { PassportModule } from '@nestjs/passport';
|
|
9
|
+
import {
|
|
10
|
+
AUTH_REFRESH_TOKEN_STORE,
|
|
11
|
+
NoopAuthRefreshTokenStore,
|
|
12
|
+
} from './auth-refresh-token.store';
|
|
13
|
+
import { AuthConfigModule } from './auth-config.module';
|
|
14
|
+
import { AuthController } from './auth.controller';
|
|
15
|
+
import { AuthService } from './auth.service';
|
|
16
|
+
import { JwtAuthGuard } from './jwt-auth.guard';
|
|
17
|
+
import { JwtStrategy } from './jwt.strategy';
|
|
18
|
+
|
|
19
|
+
export interface ForgeonAuthModuleOptions {
|
|
20
|
+
imports?: ModuleMetadata['imports'];
|
|
21
|
+
refreshTokenStoreProvider?: Provider;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Module({})
|
|
25
|
+
export class ForgeonAuthModule {
|
|
26
|
+
static register(options: ForgeonAuthModuleOptions = {}): DynamicModule {
|
|
27
|
+
const refreshTokenStoreProvider =
|
|
28
|
+
options.refreshTokenStoreProvider ??
|
|
29
|
+
({
|
|
30
|
+
provide: AUTH_REFRESH_TOKEN_STORE,
|
|
31
|
+
useClass: NoopAuthRefreshTokenStore,
|
|
32
|
+
} satisfies Provider);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
module: ForgeonAuthModule,
|
|
36
|
+
imports: [
|
|
37
|
+
AuthConfigModule,
|
|
38
|
+
PassportModule.register({ defaultStrategy: 'jwt' }),
|
|
39
|
+
JwtModule.register({}),
|
|
40
|
+
...(options.imports ?? []),
|
|
41
|
+
],
|
|
42
|
+
controllers: [AuthController],
|
|
43
|
+
providers: [AuthService, JwtStrategy, JwtAuthGuard, refreshTokenStoreProvider],
|
|
44
|
+
exports: [AuthConfigModule, AuthService, JwtAuthGuard, AUTH_REFRESH_TOKEN_STORE],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from './auth-env.schema';
|
|
2
|
+
export * from './auth-config.loader';
|
|
3
|
+
export * from './auth-config.module';
|
|
4
|
+
export * from './auth-config.service';
|
|
5
|
+
export * from './auth-refresh-token.store';
|
|
6
|
+
export * from './auth.controller';
|
|
7
|
+
export * from './auth.service';
|
|
8
|
+
export * from './auth.types';
|
|
9
|
+
export * from './dto';
|
|
10
|
+
export * from './forgeon-auth.module';
|
|
11
|
+
export * from './jwt-auth.guard';
|
|
12
|
+
export * from './jwt.strategy';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { PassportStrategy } from '@nestjs/passport';
|
|
3
|
+
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
4
|
+
import { AuthConfigService } from './auth-config.service';
|
|
5
|
+
import { AuthJwtPayload } from './auth.types';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class JwtStrategy extends PassportStrategy(Strategy) {
|
|
9
|
+
constructor(configService: AuthConfigService) {
|
|
10
|
+
super({
|
|
11
|
+
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
12
|
+
ignoreExpiration: false,
|
|
13
|
+
secretOrKey: configService.accessSecret,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
validate(payload: AuthJwtPayload): AuthJwtPayload {
|
|
18
|
+
return payload;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeon/auth-contracts",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc -p tsconfig.json"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.10.7",
|
|
19
|
+
"typescript": "^5.7.3"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const AUTH_API_ROUTES = {
|
|
2
|
+
login: '/api/auth/login',
|
|
3
|
+
refresh: '/api/auth/refresh',
|
|
4
|
+
logout: '/api/auth/logout',
|
|
5
|
+
me: '/api/auth/me',
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export const AUTH_ERROR_CODES = {
|
|
9
|
+
invalidCredentials: 'AUTH_INVALID_CREDENTIALS',
|
|
10
|
+
tokenExpired: 'AUTH_TOKEN_EXPIRED',
|
|
11
|
+
refreshInvalid: 'AUTH_REFRESH_INVALID',
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export type AuthErrorCode = (typeof AUTH_ERROR_CODES)[keyof typeof AUTH_ERROR_CODES];
|
|
15
|
+
|
|
16
|
+
export interface AuthUser {
|
|
17
|
+
sub: string;
|
|
18
|
+
email: string;
|
|
19
|
+
roles: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LoginRequest {
|
|
23
|
+
email: string;
|
|
24
|
+
password: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RefreshRequest {
|
|
28
|
+
refreshToken: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TokenPair {
|
|
32
|
+
accessToken: string;
|
|
33
|
+
refreshToken: string;
|
|
34
|
+
tokenType: 'Bearer';
|
|
35
|
+
accessTtl: string;
|
|
36
|
+
refreshTtl: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LoginResponse extends TokenPair {
|
|
40
|
+
user: AuthUser;
|
|
41
|
+
tokenStore: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RefreshResponse extends TokenPair {
|
|
45
|
+
user: AuthUser;
|
|
46
|
+
tokenStore: string;
|
|
47
|
+
}
|