@plyaz/auth 1.0.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/.github/pull_request_template.md +71 -0
- package/.github/workflows/deploy.yml +9 -0
- package/.github/workflows/publish.yml +14 -0
- package/.github/workflows/security.yml +20 -0
- package/README.md +89 -0
- package/commits.txt +5 -0
- package/dist/common/index.cjs +48 -0
- package/dist/common/index.cjs.map +1 -0
- package/dist/common/index.mjs +43 -0
- package/dist/common/index.mjs.map +1 -0
- package/dist/index.cjs +20411 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +5139 -0
- package/dist/index.mjs.map +1 -0
- package/eslint.config.mjs +13 -0
- package/index.html +13 -0
- package/package.json +141 -0
- package/src/adapters/auth-adapter-factory.ts +26 -0
- package/src/adapters/auth-adapter.mapper.ts +53 -0
- package/src/adapters/base-auth.adapter.ts +119 -0
- package/src/adapters/clerk/clerk.adapter.ts +204 -0
- package/src/adapters/custom/custom.adapter.ts +119 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/next-auth/authOptions.ts +81 -0
- package/src/adapters/next-auth/next-auth.adapter.ts +211 -0
- package/src/api/client.ts +37 -0
- package/src/audit/audit.logger.ts +52 -0
- package/src/client/components/ProtectedRoute.tsx +37 -0
- package/src/client/hooks/useAuth.ts +128 -0
- package/src/client/hooks/useConnectedAccounts.ts +108 -0
- package/src/client/hooks/usePermissions.ts +36 -0
- package/src/client/hooks/useRBAC.ts +36 -0
- package/src/client/hooks/useSession.ts +18 -0
- package/src/client/providers/AuthProvider.tsx +104 -0
- package/src/client/store/auth.store.ts +306 -0
- package/src/client/utils/storage.ts +70 -0
- package/src/common/constants/oauth-providers.ts +49 -0
- package/src/common/errors/auth.errors.ts +64 -0
- package/src/common/errors/specific-auth-errors.ts +201 -0
- package/src/common/index.ts +19 -0
- package/src/common/regex/index.ts +27 -0
- package/src/common/types/auth.types.ts +641 -0
- package/src/common/types/index.ts +297 -0
- package/src/common/utils/index.ts +84 -0
- package/src/core/blacklist/token.blacklist.ts +60 -0
- package/src/core/index.ts +2 -0
- package/src/core/jwt/jwt.manager.ts +131 -0
- package/src/core/session/session.manager.ts +56 -0
- package/src/db/repositories/connected-account.repository.ts +415 -0
- package/src/db/repositories/role.repository.ts +519 -0
- package/src/db/repositories/session.repository.ts +308 -0
- package/src/db/repositories/user.repository.ts +320 -0
- package/src/flows/index.ts +2 -0
- package/src/flows/sign-in.flow.ts +106 -0
- package/src/flows/sign-up.flow.ts +121 -0
- package/src/index.ts +54 -0
- package/src/libs/clerk.helper.ts +36 -0
- package/src/libs/supabase.helper.ts +255 -0
- package/src/libs/supabaseClient.ts +6 -0
- package/src/providers/base/auth-provider.interface.ts +42 -0
- package/src/providers/base/index.ts +1 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/oauth/facebook.provider.ts +97 -0
- package/src/providers/oauth/github.provider.ts +148 -0
- package/src/providers/oauth/google.provider.ts +126 -0
- package/src/providers/oauth/index.ts +3 -0
- package/src/rbac/dynamic-roles.ts +552 -0
- package/src/rbac/index.ts +4 -0
- package/src/rbac/permission-checker.ts +464 -0
- package/src/rbac/role-hierarchy.ts +545 -0
- package/src/rbac/role.manager.ts +75 -0
- package/src/security/csrf/csrf.protection.ts +37 -0
- package/src/security/index.ts +3 -0
- package/src/security/rate-limiting/auth/auth.controller.ts +12 -0
- package/src/security/rate-limiting/auth/rate-limiting.interface.ts +67 -0
- package/src/security/rate-limiting/auth.module.ts +32 -0
- package/src/server/auth.module.ts +158 -0
- package/src/server/decorators/auth.decorator.ts +43 -0
- package/src/server/decorators/auth.decorators.ts +31 -0
- package/src/server/decorators/current-user.decorator.ts +49 -0
- package/src/server/decorators/permission.decorator.ts +49 -0
- package/src/server/guards/auth.guard.ts +56 -0
- package/src/server/guards/custom-throttler.guard.ts +46 -0
- package/src/server/guards/permissions.guard.ts +115 -0
- package/src/server/guards/roles.guard.ts +31 -0
- package/src/server/middleware/auth.middleware.ts +46 -0
- package/src/server/middleware/index.ts +2 -0
- package/src/server/middleware/middleware.ts +11 -0
- package/src/server/middleware/session.middleware.ts +255 -0
- package/src/server/services/account.service.ts +269 -0
- package/src/server/services/auth.service.ts +79 -0
- package/src/server/services/brute-force.service.ts +98 -0
- package/src/server/services/index.ts +15 -0
- package/src/server/services/rate-limiter.service.ts +60 -0
- package/src/server/services/session.service.ts +287 -0
- package/src/server/services/token.service.ts +262 -0
- package/src/session/cookie-store.ts +255 -0
- package/src/session/enhanced-session-manager.ts +406 -0
- package/src/session/index.ts +14 -0
- package/src/session/memory-store.ts +320 -0
- package/src/session/redis-store.ts +443 -0
- package/src/strategies/oauth.strategy.ts +128 -0
- package/src/strategies/traditional-auth.strategy.ts +116 -0
- package/src/tokens/index.ts +4 -0
- package/src/tokens/refresh-token-manager.ts +448 -0
- package/src/tokens/token-validator.ts +311 -0
- package/tsconfig.build.json +28 -0
- package/tsconfig.json +38 -0
- package/tsup.config.mjs +28 -0
- package/vitest.config.mjs +16 -0
- package/vitest.setup.d.ts +2 -0
- package/vitest.setup.d.ts.map +1 -0
- package/vitest.setup.ts +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { NestMiddleware } from "@nestjs/common";
|
|
2
|
+
import { Injectable } from "@nestjs/common";
|
|
3
|
+
import type { Request, Response, NextFunction } from "express";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extends the Express Request object to include `sessionId`.
|
|
7
|
+
*/
|
|
8
|
+
export interface AuthRequest extends Request {
|
|
9
|
+
/** Optional session ID extracted from cookies or headers */
|
|
10
|
+
sessionId?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Middleware to extract the session ID from incoming requests.
|
|
15
|
+
*
|
|
16
|
+
* This middleware does **not validate** the session.
|
|
17
|
+
* It only reads the session ID from:
|
|
18
|
+
* - Cookies (`session_id`)
|
|
19
|
+
* - Headers (`x-session-id`)
|
|
20
|
+
* and attaches it to `req.sessionId` for use in guards or controllers.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* // Access sessionId in a controller or guard
|
|
24
|
+
* const sessionId = req.sessionId;
|
|
25
|
+
*/
|
|
26
|
+
@Injectable()
|
|
27
|
+
export class AuthMiddleware implements NestMiddleware {
|
|
28
|
+
/**
|
|
29
|
+
* Extracts session ID from cookies or headers and attaches it to the request object.
|
|
30
|
+
*
|
|
31
|
+
* @param req - Express request object
|
|
32
|
+
* @param res - Express response object
|
|
33
|
+
* @param next - Function to pass control to the next middleware
|
|
34
|
+
*/
|
|
35
|
+
use(req: AuthRequest, res: Response, next: NextFunction):void {
|
|
36
|
+
// Read session cookie (or header)
|
|
37
|
+
const sessionId = req.cookies?.session_id ?? req.headers["x-session-id"];
|
|
38
|
+
|
|
39
|
+
// Just attach it — NO validation
|
|
40
|
+
if (sessionId) {
|
|
41
|
+
req.sessionId = String(sessionId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
next();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The `config` object is a Next.js feature used to configure the middleware.
|
|
2
|
+
export const config = {
|
|
3
|
+
// `matcher` is an array of paths that the middleware should run on.
|
|
4
|
+
// It's a powerful tool for specifying exactly which routes need to be
|
|
5
|
+
// protected or have authentication checked.
|
|
6
|
+
matcher: [
|
|
7
|
+
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
|
8
|
+
"/api/(.*)",
|
|
9
|
+
"/trpc/(.*)",
|
|
10
|
+
],
|
|
11
|
+
};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Session middleware for @plyaz/auth
|
|
3
|
+
* @module @plyaz/auth/server/middleware/session-middleware
|
|
4
|
+
*
|
|
5
|
+
* @description
|
|
6
|
+
* NestJS middleware for session refresh management. Checks if access token
|
|
7
|
+
* is close to expiry and auto-refreshes if refresh token is valid.
|
|
8
|
+
* Sets new tokens in response cookies for seamless user experience.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { SessionMiddleware } from '@plyaz/auth';
|
|
13
|
+
*
|
|
14
|
+
* @Module({
|
|
15
|
+
* providers: [SessionMiddleware]
|
|
16
|
+
* })
|
|
17
|
+
* export class AppModule implements NestModule {
|
|
18
|
+
* configure(consumer: MiddlewareConsumer) {
|
|
19
|
+
* consumer.apply(SessionMiddleware).forRoutes('*');
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { NestMiddleware } from "@nestjs/common";
|
|
26
|
+
import { Injectable, Logger } from "@nestjs/common";
|
|
27
|
+
import type { Request, Response, NextFunction } from "express";
|
|
28
|
+
import type { TokenService } from "../services/token.service";
|
|
29
|
+
import type { SessionService } from "../services/session.service";
|
|
30
|
+
import { NUMERIX } from "@plyaz/config";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Session middleware configuration
|
|
34
|
+
*/
|
|
35
|
+
interface SessionMiddlewareConfig {
|
|
36
|
+
/** Refresh threshold in seconds (default: 300 = 5 minutes) */
|
|
37
|
+
refreshThreshold: number;
|
|
38
|
+
/** Cookie options for tokens */
|
|
39
|
+
cookieOptions: {
|
|
40
|
+
httpOnly: boolean;
|
|
41
|
+
secure: boolean;
|
|
42
|
+
sameSite: "strict" | "lax" | "none";
|
|
43
|
+
path: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Session middleware implementation
|
|
49
|
+
* Manages session refresh and token rotation
|
|
50
|
+
*/
|
|
51
|
+
@Injectable()
|
|
52
|
+
export class SessionMiddleware implements NestMiddleware {
|
|
53
|
+
private readonly logger = new Logger(SessionMiddleware.name);
|
|
54
|
+
private readonly config: SessionMiddlewareConfig;
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
private tokenService: TokenService,
|
|
58
|
+
private sessionService: SessionService
|
|
59
|
+
) {
|
|
60
|
+
this.config = {
|
|
61
|
+
refreshThreshold: 300, // 5 minutes
|
|
62
|
+
cookieOptions: {
|
|
63
|
+
httpOnly: true,
|
|
64
|
+
secure: globalThis.process.env.NODE_ENV === "production",
|
|
65
|
+
sameSite: "lax",
|
|
66
|
+
path: "/",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Middleware function to handle session refresh
|
|
73
|
+
* @param req - HTTP request
|
|
74
|
+
* @param res - HTTP response
|
|
75
|
+
* @param next - Next function
|
|
76
|
+
*/
|
|
77
|
+
async use(req: Request, res: Response, next: NextFunction): Promise<void> {
|
|
78
|
+
try {
|
|
79
|
+
const accessToken = this.extractAccessToken(req);
|
|
80
|
+
const refreshToken = this.extractRefreshToken(req);
|
|
81
|
+
|
|
82
|
+
if (!accessToken || !refreshToken) {
|
|
83
|
+
// No tokens present, continue without refresh
|
|
84
|
+
return next();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check if access token needs refresh
|
|
88
|
+
const shouldRefresh = await this.shouldRefreshToken(accessToken);
|
|
89
|
+
|
|
90
|
+
if (shouldRefresh) {
|
|
91
|
+
await this.refreshTokens(req, res, refreshToken);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
next();
|
|
95
|
+
} catch (error) {
|
|
96
|
+
this.logger.error("Session middleware error", error);
|
|
97
|
+
// Don't block the request on refresh errors
|
|
98
|
+
next();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if token should be refreshed
|
|
104
|
+
* @param accessToken - Access token to check
|
|
105
|
+
* @returns True if token should be refreshed
|
|
106
|
+
* @private
|
|
107
|
+
*/
|
|
108
|
+
private async shouldRefreshToken(accessToken: string): Promise<boolean> {
|
|
109
|
+
try {
|
|
110
|
+
const timeUntilExpiry =
|
|
111
|
+
this.tokenService.getTimeUntilExpiration(accessToken);
|
|
112
|
+
|
|
113
|
+
if (timeUntilExpiry === null) {
|
|
114
|
+
return false; // Invalid token, don't refresh
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Refresh if token expires within threshold
|
|
118
|
+
return timeUntilExpiry <= this.config.refreshThreshold;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
this.logger.debug("Error checking token expiry", error);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Refresh tokens and update cookies
|
|
127
|
+
* @param req - HTTP request
|
|
128
|
+
* @param res - HTTP response
|
|
129
|
+
* @param refreshToken - Refresh token
|
|
130
|
+
* @private
|
|
131
|
+
*/
|
|
132
|
+
private async refreshTokens(
|
|
133
|
+
req: Request,
|
|
134
|
+
res: Response,
|
|
135
|
+
refreshToken: string
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
try {
|
|
138
|
+
this.logger.debug("Refreshing tokens");
|
|
139
|
+
|
|
140
|
+
// Validate refresh token first
|
|
141
|
+
const validationResult =
|
|
142
|
+
await this.tokenService.validateRefreshToken(refreshToken);
|
|
143
|
+
|
|
144
|
+
if (!validationResult.valid) {
|
|
145
|
+
this.logger.warn("Invalid refresh token, clearing cookies");
|
|
146
|
+
this.clearTokenCookies(res);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Get user roles and permissions for new tokens
|
|
151
|
+
const payload = validationResult.payload;
|
|
152
|
+
const userRoles = payload?.roles;
|
|
153
|
+
const userPermissions = payload?.permissions;
|
|
154
|
+
|
|
155
|
+
// Generate new token pair
|
|
156
|
+
const tokenPair = await this.tokenService.refreshTokenPair(
|
|
157
|
+
refreshToken,
|
|
158
|
+
userRoles,
|
|
159
|
+
userPermissions
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
// Set new tokens in cookies
|
|
163
|
+
this.setTokenCookies(res, tokenPair.accessToken, tokenPair.refreshToken);
|
|
164
|
+
|
|
165
|
+
// Update request with new access token for downstream handlers
|
|
166
|
+
this.updateRequestToken(req, tokenPair.accessToken);
|
|
167
|
+
|
|
168
|
+
this.logger.log("Tokens refreshed successfully");
|
|
169
|
+
} catch (error) {
|
|
170
|
+
this.logger.error("Failed to refresh tokens", error);
|
|
171
|
+
// Clear invalid tokens
|
|
172
|
+
this.clearTokenCookies(res);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Extract access token from request
|
|
178
|
+
* @param req - HTTP request
|
|
179
|
+
* @returns Access token or null
|
|
180
|
+
* @private
|
|
181
|
+
*/
|
|
182
|
+
private extractAccessToken(req: Request): string | null {
|
|
183
|
+
// Check Authorization header first
|
|
184
|
+
const authHeader = req.headers.authorization;
|
|
185
|
+
if (authHeader?.startsWith("Bearer ")) {
|
|
186
|
+
const seven = 7;
|
|
187
|
+
return authHeader.substring(seven);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Check cookies
|
|
191
|
+
return req.cookies?.accessToken ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Extract refresh token from request
|
|
196
|
+
* @param req - HTTP request
|
|
197
|
+
* @returns Refresh token or null
|
|
198
|
+
* @private
|
|
199
|
+
*/
|
|
200
|
+
private extractRefreshToken(req: Request): string | null {
|
|
201
|
+
return req.cookies?.refreshToken ?? null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Set token cookies in response
|
|
206
|
+
* @param res - HTTP response
|
|
207
|
+
* @param accessToken - Access token
|
|
208
|
+
* @param refreshToken - Refresh token
|
|
209
|
+
* @private
|
|
210
|
+
*/
|
|
211
|
+
private setTokenCookies(
|
|
212
|
+
res: Response,
|
|
213
|
+
accessToken: string,
|
|
214
|
+
refreshToken: string
|
|
215
|
+
): void {
|
|
216
|
+
const fifteen = 15;
|
|
217
|
+
// Set access token cookie (shorter expiry)
|
|
218
|
+
res.cookie("accessToken", accessToken, {
|
|
219
|
+
...this.config.cookieOptions,
|
|
220
|
+
maxAge: fifteen * NUMERIX.SIXTY * NUMERIX.THOUSAND, // 15 minutes
|
|
221
|
+
});
|
|
222
|
+
const seven = 7;
|
|
223
|
+
// Set refresh token cookie (longer expiry)
|
|
224
|
+
res.cookie("refreshToken", refreshToken, {
|
|
225
|
+
...this.config.cookieOptions,
|
|
226
|
+
maxAge:
|
|
227
|
+
seven *
|
|
228
|
+
NUMERIX.TWENTY_FOUR *
|
|
229
|
+
NUMERIX.SIXTY *
|
|
230
|
+
NUMERIX.SIXTY *
|
|
231
|
+
NUMERIX.THOUSAND, // 7 days
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Clear token cookies from response
|
|
237
|
+
* @param res - HTTP response
|
|
238
|
+
* @private
|
|
239
|
+
*/
|
|
240
|
+
private clearTokenCookies(res: Response): void {
|
|
241
|
+
res.clearCookie("accessToken", this.config.cookieOptions);
|
|
242
|
+
res.clearCookie("refreshToken", this.config.cookieOptions);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Update request with new access token
|
|
247
|
+
* @param req - HTTP request
|
|
248
|
+
* @param accessToken - New access token
|
|
249
|
+
* @private
|
|
250
|
+
*/
|
|
251
|
+
private updateRequestToken(req: Request, accessToken: string): void {
|
|
252
|
+
// Update Authorization header for downstream handlers
|
|
253
|
+
req.headers.authorization = `Bearer ${accessToken}`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Account service for @plyaz/auth
|
|
3
|
+
* @module @plyaz/auth/server/services/account-service
|
|
4
|
+
*
|
|
5
|
+
* @description
|
|
6
|
+
* NestJS service for connected account management operations.
|
|
7
|
+
* Provides high-level operations for linking/unlinking provider accounts,
|
|
8
|
+
* managing primary accounts, and handling account-related business logic.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { AccountService } from '@plyaz/auth';
|
|
13
|
+
*
|
|
14
|
+
* @Controller('accounts')
|
|
15
|
+
* export class AccountsController {
|
|
16
|
+
* constructor(private accountService: AccountService) {}
|
|
17
|
+
*
|
|
18
|
+
* @Post('link')
|
|
19
|
+
* async linkAccount(@Body() data, @CurrentUser() user) {
|
|
20
|
+
* return await this.accountService.link(user.id, data.provider, data);
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Injectable, Logger } from '@nestjs/common';
|
|
27
|
+
import type { ConnectedAccountRepository } from '../../db/repositories/connected-account.repository';
|
|
28
|
+
|
|
29
|
+
import type { ConnectedAccount } from '@plyaz/types';
|
|
30
|
+
import { AUTH_EVENTS } from '@plyaz/types';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Account service implementation
|
|
34
|
+
* Provides connected account management operations for NestJS applications
|
|
35
|
+
*/
|
|
36
|
+
@Injectable()
|
|
37
|
+
export class AccountService {
|
|
38
|
+
private readonly logger = new Logger(AccountService.name);
|
|
39
|
+
|
|
40
|
+
constructor(private accountRepository: ConnectedAccountRepository) {}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Link account to user
|
|
44
|
+
* INSERT new connected account and emit account.linked event
|
|
45
|
+
* @param userId - User identifier
|
|
46
|
+
* @param provider - Provider name
|
|
47
|
+
* @param providerData - Provider account data
|
|
48
|
+
* @returns Created connected account
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
async link(userId: string, provider: string, providerData: ConnectedAccount): Promise<ConnectedAccount> {
|
|
52
|
+
this.logger.debug(`Linking ${provider} account for user: ${userId}`);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
// Link the account
|
|
58
|
+
const connectedAccount = await this.accountRepository.linkAccount(
|
|
59
|
+
userId,
|
|
60
|
+
provider,
|
|
61
|
+
providerData
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
this.logger.log(`${provider} account linked for user: ${userId}`);
|
|
65
|
+
this.emitEvent(AUTH_EVENTS.ACCOUNT_LINKED, {
|
|
66
|
+
userId,
|
|
67
|
+
provider,
|
|
68
|
+
accountId: connectedAccount.id,
|
|
69
|
+
timestamp: new Date()
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return connectedAccount;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
this.logger.error(`Failed to link ${provider} account for user: ${userId}`, error);
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Unlink account from user
|
|
81
|
+
* DELETE connected account with validation and emit account.unlinked event
|
|
82
|
+
* @param userId - User identifier
|
|
83
|
+
* @param accountId - Account identifier
|
|
84
|
+
*/
|
|
85
|
+
async unlink(userId: string, accountId: string): Promise<void> {
|
|
86
|
+
this.logger.debug(`Unlinking account ${accountId} for user: ${userId}`);
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
// Get account details
|
|
90
|
+
const account = await this.accountRepository.findById(accountId);
|
|
91
|
+
if (!account) {
|
|
92
|
+
// throw new UserNotFoundError('Connected account not found');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Verify account belongs to user
|
|
96
|
+
if (account?.userId !== userId) {
|
|
97
|
+
throw new Error('Account does not belong to user');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Unlink the account (includes validation for last auth method)
|
|
101
|
+
await this.accountRepository.unlinkAccount(accountId);
|
|
102
|
+
|
|
103
|
+
this.logger.log(`Account ${accountId} unlinked for user: ${userId}`);
|
|
104
|
+
this.emitEvent(AUTH_EVENTS.ACCOUNT_UNLINKED, {
|
|
105
|
+
userId,
|
|
106
|
+
provider: account?.provider,
|
|
107
|
+
accountId,
|
|
108
|
+
timestamp: new Date()
|
|
109
|
+
});
|
|
110
|
+
} catch (error) {
|
|
111
|
+
this.logger.error(`Failed to unlink account ${accountId} for user: ${userId}`, error);
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get all connected accounts for user
|
|
118
|
+
* @param userId - User identifier
|
|
119
|
+
* @returns Array of connected accounts
|
|
120
|
+
*/
|
|
121
|
+
async getAll(userId: string): Promise<ConnectedAccount[]> {
|
|
122
|
+
this.logger.debug(`Getting all accounts for user: ${userId}`);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
return await this.accountRepository.findByUserId(userId);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
this.logger.error(`Failed to get accounts for user: ${userId}`, error);
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Set primary connected account
|
|
134
|
+
* @param userId - User identifier
|
|
135
|
+
* @param accountId - Account identifier to set as primary
|
|
136
|
+
*/
|
|
137
|
+
async setPrimary(userId: string, accountId: string): Promise<void> {
|
|
138
|
+
this.logger.debug(`Setting primary account ${accountId} for user: ${userId}`);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
// Verify account belongs to user
|
|
142
|
+
const account = await this.accountRepository.findById(accountId);
|
|
143
|
+
if (account?.userId !== userId) {
|
|
144
|
+
throw new Error('Account not found or does not belong to user');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Set as primary
|
|
148
|
+
await this.accountRepository.setPrimary(userId, accountId);
|
|
149
|
+
|
|
150
|
+
this.logger.log(`Primary account set to ${accountId} for user: ${userId}`);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
this.logger.error(`Failed to set primary account ${accountId} for user: ${userId}`, error);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get primary connected account for user
|
|
159
|
+
* @param userId - User identifier
|
|
160
|
+
* @returns Primary connected account or null
|
|
161
|
+
*/
|
|
162
|
+
async getPrimary(userId: string): Promise<ConnectedAccount | null> {
|
|
163
|
+
this.logger.debug(`Getting primary account for user: ${userId}`);
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
return await this.accountRepository.findPrimary(userId);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
this.logger.error(`Failed to get primary account for user: ${userId}`, error);
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Find account by provider credentials
|
|
175
|
+
* @param provider - Provider name
|
|
176
|
+
* @param providerAccountId - Provider account ID
|
|
177
|
+
* @returns Connected account or null
|
|
178
|
+
*/
|
|
179
|
+
async findByProvider(provider: string, providerAccountId: string): Promise<ConnectedAccount | null> {
|
|
180
|
+
this.logger.debug(`Finding account by provider: ${provider}, ID: ${providerAccountId}`);
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
return await this.accountRepository.findByProvider(provider, providerAccountId);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
this.logger.error(`Failed to find account by provider: ${provider}`, error);
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Update account tokens
|
|
192
|
+
* @param accountId - Account identifier
|
|
193
|
+
* @param accessToken - New access token
|
|
194
|
+
* @param refreshToken - New refresh token (optional)
|
|
195
|
+
*/
|
|
196
|
+
async updateTokens(accountId: string, accessToken: string, refreshToken?: string): Promise<void> {
|
|
197
|
+
this.logger.debug(`Updating tokens for account: ${accountId}`);
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
await this.accountRepository.updateTokens(accountId, accessToken, refreshToken);
|
|
201
|
+
this.logger.log(`Tokens updated for account: ${accountId}`);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
this.logger.error(`Failed to update tokens for account: ${accountId}`, error);
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get account by ID
|
|
210
|
+
* @param accountId - Account identifier
|
|
211
|
+
* @returns Connected account or null
|
|
212
|
+
*/
|
|
213
|
+
async getById(accountId: string): Promise<ConnectedAccount | null> {
|
|
214
|
+
this.logger.debug(`Getting account by ID: ${accountId}`);
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
return await this.accountRepository.findById(accountId);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
this.logger.error(`Failed to get account by ID: ${accountId}`, error);
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Check if user has account with provider
|
|
226
|
+
* @param userId - User identifier
|
|
227
|
+
* @param provider - Provider name
|
|
228
|
+
* @returns True if user has account with provider
|
|
229
|
+
*/
|
|
230
|
+
async hasProviderAccount(userId: string, provider: string): Promise<boolean> {
|
|
231
|
+
this.logger.debug(`Checking if user ${userId} has ${provider} account`);
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const accounts = await this.accountRepository.findByUserId(userId);
|
|
235
|
+
return accounts.some(account => account.provider === provider && account.isActive);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
this.logger.error(`Failed to check provider account for user: ${userId}`, error);
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Get account count for user
|
|
244
|
+
* @param userId - User identifier
|
|
245
|
+
* @returns Number of connected accounts
|
|
246
|
+
*/
|
|
247
|
+
async getAccountCount(userId: string): Promise<number> {
|
|
248
|
+
this.logger.debug(`Getting account count for user: ${userId}`);
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const accounts = await this.accountRepository.findByUserId(userId);
|
|
252
|
+
return accounts.filter(account => account.isActive).length;
|
|
253
|
+
} catch (error) {
|
|
254
|
+
this.logger.error(`Failed to get account count for user: ${userId}`, error);
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Emit event (mock implementation)
|
|
261
|
+
* @param eventType - Event type
|
|
262
|
+
* @param payload - Event payload
|
|
263
|
+
* @private
|
|
264
|
+
*/
|
|
265
|
+
private emitEvent(eventType: string, payload: unknown): void {
|
|
266
|
+
// Mock event emission - in real implementation would use event system
|
|
267
|
+
this.logger.debug(`Event: ${eventType}`, payload);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import type { SessionManager } from '../../core/session/session.manager';
|
|
4
|
+
import type { TraditionalAuthStrategy } from '../../strategies/traditional-auth.strategy';
|
|
5
|
+
import type { OAuthStrategy, OAuthProfile } from '../../strategies/oauth.strategy';
|
|
6
|
+
import type { UserRepository } from '../../db/repositories/user.repository';
|
|
7
|
+
import type { User, AuthTokens } from '../../common/types/auth.types';
|
|
8
|
+
import type { Session } from '@plyaz/types';
|
|
9
|
+
import type { JwtManager } from '@/core/jwt/jwt.manager';
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@Injectable()
|
|
13
|
+
export class AuthService {
|
|
14
|
+
// eslint-disable-next-line max-params
|
|
15
|
+
constructor(
|
|
16
|
+
private jwtManager:JwtManager,
|
|
17
|
+
private sessionManager: SessionManager,
|
|
18
|
+
private traditionalAuth: TraditionalAuthStrategy,
|
|
19
|
+
private oauthAuth: OAuthStrategy,
|
|
20
|
+
private userRepo: UserRepository
|
|
21
|
+
) {}
|
|
22
|
+
|
|
23
|
+
async signIn(email: string, password: string, deviceInfo: Record<string,unknown>): Promise<{ user: User; tokens: AuthTokens; session: Session }> {
|
|
24
|
+
const user = await this.traditionalAuth.authenticate(email, password);
|
|
25
|
+
const session = await this.sessionManager.createSession(user.id, deviceInfo);
|
|
26
|
+
const tokens = this.jwtManager.generateTokens(user);
|
|
27
|
+
|
|
28
|
+
await this.userRepo.updateLastLogin(user.id);
|
|
29
|
+
|
|
30
|
+
return { user, tokens: { ...tokens, refreshToken: tokens.refreshToken ?? '' }, session };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async signUp(userData: Partial<User>, password: string): Promise<User> {
|
|
34
|
+
const passwordHash = await this.traditionalAuth.hashPassword(password);
|
|
35
|
+
|
|
36
|
+
return this.userRepo.create({
|
|
37
|
+
email: userData.email!,
|
|
38
|
+
displayName: userData.displayName!,
|
|
39
|
+
passwordHash,
|
|
40
|
+
authProvider: 'email',
|
|
41
|
+
isActive: true,
|
|
42
|
+
...userData,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async signOut(sessionId: string): Promise<void> {
|
|
47
|
+
await this.sessionManager.invalidateSession(sessionId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async signOutAll(userId: string): Promise<void> {
|
|
51
|
+
await this.sessionManager.invalidateAllUserSessions(userId);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async refreshToken(refreshToken: string): Promise<AuthTokens> {
|
|
55
|
+
const payload = this.jwtManager.verifyRefreshToken(refreshToken);
|
|
56
|
+
const user = await this.userRepo.findById(payload.sub);
|
|
57
|
+
|
|
58
|
+
if (!user?.isActive) {
|
|
59
|
+
throw new Error('Invalid refresh token');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tokens = this.jwtManager.generateTokens(user);
|
|
63
|
+
return { ...tokens, refreshToken: tokens.refreshToken ?? '' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async validateUser(userId: string): Promise<User | null> {
|
|
67
|
+
return this.userRepo.findById(userId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async oauthSignIn(profile: OAuthProfile, accessToken: string, refreshToken?: string, deviceInfo?: Record<string,unknown>): Promise<{ user: User; tokens: AuthTokens; session: Session }> {
|
|
71
|
+
const user = await this.oauthAuth.authenticate(profile, accessToken, refreshToken);
|
|
72
|
+
const session = await this.sessionManager.createSession(user.id, deviceInfo ?? {});
|
|
73
|
+
const tokens = this.jwtManager.generateTokens(user);
|
|
74
|
+
|
|
75
|
+
await this.userRepo.updateLastLogin(user.id);
|
|
76
|
+
|
|
77
|
+
return { user, tokens: { ...tokens, refreshToken: tokens.refreshToken ?? '' }, session };
|
|
78
|
+
}
|
|
79
|
+
}
|