@flusys/nestjs-auth 9.1.2 → 9.2.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/README.md CHANGED
@@ -51,6 +51,15 @@ AuthModule.forRoot({
51
51
 
52
52
  Browser logins get the refresh token as an HttpOnly cookie lasting `refreshTokenExpiration`. Sending `rememberMe: false` in the body of `POST /auth/login` or `POST /auth/select` makes it a session cookie instead (dropped when the browser closes).
53
53
 
54
+ ### Tokens and sessions
55
+
56
+ - Access and refresh tokens are HS256 JWTs carrying `type: 'access' | 'refresh'`. `JwtStrategy` accepts only `access` tokens and `POST /auth/refresh` only `refresh` tokens, so one can never stand in for the other, even when both share a secret (outside production `refreshTokenSecret` falls back to `jwtSecret`).
57
+ - Refresh tokens are stateless and are not rotated: `POST /auth/refresh` returns a new access token only. They end at `refreshTokenExpiration` or when the user's sessions are revoked: logout, deactivation, a password reset and a social sign-in that claims an unverified account all record a revocation time, and every token issued before that moment is refused. Tokens carry an `iatMs` claim (issue time in milliseconds), so a token issued earlier in the same second as the revocation is refused while one issued right after it (signing straight back in) is kept; a token from before `iatMs` existed is judged by `iat` and, issued in the revocation's second, is refused. The revocation marker (`auth:revoked:<userId>`, epoch ms, kept for `refreshTokenExpiration`) is read and written past the in-process cache layer and never moves backwards. A company-selection session started before a revocation cannot complete `POST /auth/select` either.
58
+ - Password reset and email verification tokens are 32 random bytes, sent raw and stored only as their SHA-256 hash. A reset token lasts one hour and is single-use even under concurrent requests (it is claimed with one conditional update before the password changes); a verification link lasts 24 hours.
59
+ - Login answers an unknown email, a wrong password and a password-less (social-only) account with the same `auth.login.invalid.credentials`, running a bcrypt comparison in every case. A pending registration is only reported to someone who knows its password. `forgot-password` answers the same whether or not the email exists, a failing email provider included. Brute force is limited by the `@Throttle` limits on each route; there is no per-account lockout.
60
+ - With the company feature on, `POST /auth/register` with an email that already has an account joins or creates another company for that account only when the request carries the account's current password (otherwise it is refused like any taken email), the account is active, and, with email verification on, its email is verified.
61
+ - `POST /administration/users/profile` only ever edits the caller's own profile (an `id` naming anyone else is refused with `auth.profile.access.denied`); changing your password there always needs `oldPassword`, and changing your email clears `emailVerified`. Administrators edit other users through `POST /administration/users/update`.
62
+
54
63
  #### Mode 2: Multi-Tenant
55
64
 
56
65
  In multi-tenant mode you provide:
@@ -272,7 +281,7 @@ AuthModule.forRootAsync({ ..., providers: [userEnricherProvider] });
272
281
 
273
282
  Google, Facebook, LinkedIn and Microsoft are built in. Nothing is enabled until an
274
283
  administrator saves credentials, and no flag gates the feature: the
275
- `social_auth_config` and `user_social_account` tables are always registered, and
284
+ `social_auth_config` table is always registered, and
276
285
  `SocialAuthController` / `SocialAuthConfigController` are always mounted.
277
286
 
278
287
  ### Credentials
@@ -303,23 +312,36 @@ The `state` is a single-use, cache-backed token that also records which provider
303
312
  and redirect URI the sign-in started with — providers echo back nothing but
304
313
  `code` and `state`, so the callback carries no provider of its own. Redeeming a
305
314
  state deletes it, and a redirect URI that does not match the one the sign-in
306
- started with is rejected.
315
+ started with is rejected. The state is redeemed with an atomic `take`, so it (and the
316
+ `code` that comes with it) cannot be replayed on another instance. The state is not bound to the browser that started the
317
+ sign-in, so the client must keep the `state` it got from `authorize` and refuse a
318
+ callback whose `state` differs before posting it (login CSRF).
307
319
 
308
320
  The callback returns exactly what `POST /auth/login` returns, company selection
309
321
  included, because both routes finish through `AuthenticationService.issueLoginForUser`.
310
322
 
311
323
  ### How a user is matched
312
324
 
313
- 1. The provider's own account id, via `user_social_account` — survives an email change at the provider.
314
- 2. Otherwise the email, so an existing password account is adopted rather than duplicated.
315
- 3. Otherwise a new account with no password.
325
+ 1. The email, so an existing password account is adopted rather than duplicated.
326
+ 2. Otherwise a new account with no password.
316
327
 
317
- Steps 2 and 3 require the provider to have **verified** the address. Matching on an
328
+ Both require the provider to have **verified** the address. Matching on an
318
329
  unverified email would let anyone who registers someone else's address at the
319
330
  provider walk into their FLUSYS account, so an unverified profile is rejected.
320
- An account already linked at step 1 is unaffected.
321
331
 
322
- Step 3 — creating an account — is refused in two cases, each with its own message key:
332
+ Adopting an account whose own email was never verified in FLUSYS drops its
333
+ password and ends its sessions: whoever chose that password never proved they own
334
+ the address, so an account pre-registered with someone else's email cannot be kept
335
+ by its registrant once the real owner signs in. The owner can set a password again
336
+ through `forgot-password`.
337
+
338
+ Microsoft's `mail` claim is set by the directory's administrator, so it only counts
339
+ as verified when sign-in is pinned to one directory (`extraConfig.tenantId` set to
340
+ your tenant id or domain) or to personal accounts (`consumers`). With the default
341
+ `common` (or `organizations`) authority every Microsoft profile is treated as
342
+ unverified and refused - set `tenantId` to use Microsoft sign-in.
343
+
344
+ Step 2 — creating an account — is refused in two cases, each with its own message key:
323
345
 
324
346
  | Condition | Result |
325
347
  | ------------------------------------------- | -------------------------------------------- |
@@ -335,6 +357,16 @@ then works. Signing **in** is never affected by either — only account creation
335
357
  A cache instance is required: `AuthModule` imports `CacheModule`, so this is
336
358
  satisfied by default.
337
359
 
360
+ ### Caching
361
+
362
+ `UserService`, `CompanyService`, `BranchService` and `SocialAuthConfigService` cache `findById` / `get-all` through `ApiService` version stamps (see `nestjs-shared` section 7). Notes specific to this package:
363
+
364
+ - Every write outside `ApiService`'s own CRUD moves the stamp of what it wrote, after commit: login (`lastLoginAt`), registration (user, plus company / branch / grants when it creates them), password change and reset, email verification, profile and status updates, social sign-in (new or claimed account), grant assign / revoke (`UserPermissionService`), and the grants removed by a company or branch delete.
365
+ - The user list depends on `appuser` and `user_company_permission` (the company-scoped list reads each user's grant). A list filtered by `actionCode` reads IAM grants, whose writes never move these stamps, so that read is not cached.
366
+ - `SocialAuthConfigService` returns `hasClientSecret` instead of the secret, so the client secret is never cached or sent.
367
+ - Login, token refresh and `JwtStrategy` read user, company and grant state from the database, never from the cache.
368
+ - One-time state never goes through the in-process layer: company-selection sessions (keyed by tenant in multi-tenant mode, consumed with `take`), social sign-in state (`take`) and revocation markers. Revocation markers are keyed by user id only, so in multi-tenant mode a revocation in one tenant also ends the sessions of a user with the same id in another tenant (only when tenant databases share user ids).
369
+
338
370
  ## 6. Branch Hierarchy
339
371
 
340
372
  Branches nest through `parentId`. A parent must be in the same company and cannot create a cycle, and deleting a branch also deletes every branch under it. `BranchService` exposes the hierarchy, each lookup taking two queries whatever the depth:
@@ -349,7 +381,7 @@ With the company feature on, the module also provides and exports `BRANCH_HIERAR
349
381
 
350
382
  ## 7. Deactivating a User
351
383
 
352
- With the company feature off, `UserService.updateStatus` sets `AppUser.isActive`, which blocks login, token refresh and every request. With it on, it deactivates the user in the admin's current company only, by setting `isActive` on that company grant (`UserCompanyPermission`, `permissionType: company`). Login, company select, select/switch, token refresh, `get-my-companies` / `get-my-company-branches` and `COMPANY_ACCESS_RESOLVER` all skip an inactive company grant, and deactivating (in either mode) also ends every session of the user at once, the same way logout does (`revokeUserSessions`, checked on refresh and by `JwtStrategy` on every request). The user can log straight back in to the companies they are still active in. A user with company grants never gets a company-less login (such a token is not filtered by company): login is refused when they are deactivated in every company (`auth.login.account.deactivated`) or none of their companies is active (`auth.company.not.found`), and a company-less refresh token is not renewed once the user has company grants. Branch grants carry no status of their own.
384
+ With the company feature off, `UserService.updateStatus` sets `AppUser.isActive`, which blocks login, token refresh and every request. With it on, it deactivates the user in the admin's current company only, by setting `isActive` on that company grant (`UserCompanyPermission`, `permissionType: company`). Login, company select, select/switch, token refresh, `get-my-companies` / `get-my-company-branches` and `COMPANY_ACCESS_RESOLVER` all skip an inactive company grant, and deactivating (in either mode) also ends every session of the user at once, the same way logout does (`revokeUserSessions`, checked on refresh and by `JwtStrategy` on every request). The user can log straight back in to the companies they are still active in. A user with company grants never gets a company-less login (such a token is not filtered by company): login is refused when they are deactivated in every company (`auth.login.account.deactivated`) or none of their companies is active (`auth.company.not.found`), and a company-less refresh token is not renewed once the user has company grants. Branch grants carry no status of their own. With the company feature on, a caller whose token has no company cannot change a status (`auth.company.required`) and sees no users in `get-all` / `lookup`, matching the company-less scoping of `nestjs-shared`; the company-select routes (`get-my-companies`, `get-my-company-branches`, `switch-company`) work from the token's user id alone, so such a caller can still pick a company.
353
385
 
354
386
  ## License
355
387
 
@@ -4,6 +4,7 @@ declare const BranchController_base: abstract new (service: BranchService) => {
4
4
  readonly enabledEndpoints: import("@flusys/nestjs-shared").ApiEndpoint[] | "all";
5
5
  service: BranchService;
6
6
  isEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): boolean;
7
+ assertEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): void;
7
8
  insert(addDto: CreateCompanyBranchDto, user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").SingleResponseDto<CompanyBranchResponseDto>>;
8
9
  insertMany(addDto: CreateCompanyBranchDto[], user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").BulkResponseDto<CompanyBranchResponseDto>>;
9
10
  getById(id: string, body: import("@flusys/nestjs-shared").GetByIdBodyDto, user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").SingleResponseDto<CompanyBranchResponseDto>>;
@@ -4,6 +4,7 @@ declare const CompanyController_base: abstract new (service: CompanyService) =>
4
4
  readonly enabledEndpoints: import("@flusys/nestjs-shared").ApiEndpoint[] | "all";
5
5
  service: CompanyService;
6
6
  isEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): boolean;
7
+ assertEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): void;
7
8
  insert(addDto: CreateCompanyDto, user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").SingleResponseDto<CompanyResponseDto>>;
8
9
  insertMany(addDto: CreateCompanyDto[], user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").BulkResponseDto<CompanyResponseDto>>;
9
10
  getById(id: string, body: import("@flusys/nestjs-shared").GetByIdBodyDto, user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").SingleResponseDto<CompanyResponseDto>>;
@@ -4,6 +4,7 @@ declare const SocialAuthConfigController_base: abstract new (service: SocialAuth
4
4
  readonly enabledEndpoints: import("@flusys/nestjs-shared").ApiEndpoint[] | "all";
5
5
  service: SocialAuthConfigService;
6
6
  isEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): boolean;
7
+ assertEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): void;
7
8
  insert(addDto: CreateSocialAuthConfigDto, user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").SingleResponseDto<SocialAuthConfigResponseDto>>;
8
9
  insertMany(addDto: CreateSocialAuthConfigDto[], user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").BulkResponseDto<SocialAuthConfigResponseDto>>;
9
10
  getById(id: string, body: import("@flusys/nestjs-shared").GetByIdBodyDto, user: import("@flusys/nestjs-shared").ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").SingleResponseDto<SocialAuthConfigResponseDto>>;
@@ -7,6 +7,7 @@ declare const UserController_base: abstract new (service: UserService) => {
7
7
  readonly enabledEndpoints: import("@flusys/nestjs-shared").ApiEndpoint[] | "all";
8
8
  service: UserService;
9
9
  isEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): boolean;
10
+ assertEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): void;
10
11
  insert(addDto: CreateUserDto, user: ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").SingleResponseDto<UserResponseDto>>;
11
12
  insertMany(addDto: CreateUserDto[], user: ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").BulkResponseDto<UserResponseDto>>;
12
13
  getById(id: string, body: import("@flusys/nestjs-shared").GetByIdBodyDto, user: ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").SingleResponseDto<UserResponseDto>>;
package/fesm/118.js CHANGED
@@ -752,7 +752,7 @@ _ts_decorate([
752
752
  description: 'Whether a client secret is currently stored for this provider'
753
753
  }),
754
754
  (0,class_transformer__rspack_import_2.Expose)(),
755
- (0,class_transformer__rspack_import_2.Transform)(({ obj })=>!!obj.clientSecret),
755
+ (0,class_transformer__rspack_import_2.Transform)(({ obj })=>obj.hasClientSecret ?? !!obj.clientSecret),
756
756
  _ts_metadata("design:type", Boolean)
757
757
  ], SocialAuthConfigResponseDto.prototype, "hasClientSecret", void 0);
758
758
  _ts_decorate([
package/fesm/120.js CHANGED
@@ -1474,7 +1474,6 @@ user_controller_ts_decorate([
1474
1474
  (0,common_.Post)('profile-sections'),
1475
1475
  (0,common_.UseGuards)(guards_.JwtAuthGuard),
1476
1476
  (0,swagger_.ApiBearerAuth)(),
1477
- (0,__rspack_external__flusys_nestjs_shared_decorators_c129b601.RequirePermission)(classes_.USER_PERMISSIONS.READ),
1478
1477
  (0,swagger_.ApiOperation)({
1479
1478
  summary: 'Get profile section definitions',
1480
1479
  description: 'Returns available profile sections for multi-step profile (if enricher configured).'
@@ -2137,6 +2136,7 @@ class ClearToken {
2137
2136
  ClearToken = _ts_decorate([
2138
2137
  (0,_nestjs_common__rspack_import_1.Injectable)(),
2139
2138
  _ts_param(0, (0,_nestjs_common__rspack_import_1.Optional)()),
2139
+ _ts_param(0, (0,_nestjs_common__rspack_import_1.Inject)(_services_auth_config_service_js__rspack_import_3/* .AuthConfigService */.W)),
2140
2140
  _ts_metadata("design:type", Function),
2141
2141
  _ts_metadata("design:paramtypes", [
2142
2142
  typeof _services_auth_config_service_js__rspack_import_3/* .AuthConfigService */.W === "undefined" ? Object : _services_auth_config_service_js__rspack_import_3/* .AuthConfigService */.W