@flusys/nestjs-auth 7.0.2 → 7.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/README.md +67 -0
- package/cjs/config/auth.constants.js +8 -0
- package/cjs/config/event-actions.js +4 -2
- package/cjs/config/message-keys.js +27 -0
- package/cjs/controllers/index.js +2 -0
- package/cjs/controllers/social-auth-config.controller.js +107 -0
- package/cjs/controllers/social-auth.controller.js +175 -0
- package/cjs/controllers/social-auth.controller.spec.js +79 -0
- package/cjs/docs/auth-swagger.config.js +9 -1
- package/cjs/dtos/index.js +1 -0
- package/cjs/dtos/social-auth.dto.js +318 -0
- package/cjs/entities/index.js +3 -1
- package/cjs/entities/index.spec.js +12 -1
- package/cjs/entities/social-auth-config.entity.js +102 -0
- package/cjs/enums/index.js +1 -0
- package/cjs/enums/social-provider-type.enum.js +17 -0
- package/cjs/index.js +1 -0
- package/cjs/interfaces/index.js +1 -0
- package/cjs/interfaces/social-auth.interface.js +4 -0
- package/cjs/modules/auth.module.js +6 -0
- package/cjs/modules/auth.module.spec.js +15 -0
- package/cjs/providers/facebook.social-provider.js +73 -0
- package/cjs/providers/google.social-provider.js +68 -0
- package/cjs/providers/index.js +18 -0
- package/cjs/providers/linkedin.social-provider.js +66 -0
- package/cjs/providers/microsoft.social-provider.js +78 -0
- package/cjs/providers/oauth-http.util.js +107 -0
- package/cjs/providers/social-provider.registry.js +24 -0
- package/cjs/providers/social-provider.registry.spec.js +135 -0
- package/cjs/services/auth-config.service.js +3 -0
- package/cjs/services/auth-config.service.spec.js +3 -0
- package/cjs/services/auth-datasource.provider.js +4 -0
- package/cjs/services/auth-datasource.provider.spec.js +30 -0
- package/cjs/services/authentication.service.js +38 -8
- package/cjs/services/authentication.service.spec.js +58 -0
- package/cjs/services/index.js +2 -0
- package/cjs/services/social-auth-config.service.js +103 -0
- package/cjs/services/social-auth-config.service.spec.js +71 -0
- package/cjs/services/social-auth.service.js +310 -0
- package/cjs/services/social-auth.service.spec.js +310 -0
- package/config/auth.constants.d.ts +2 -0
- package/config/event-actions.d.ts +2 -0
- package/config/message-keys.d.ts +21 -0
- package/controllers/index.d.ts +2 -0
- package/controllers/social-auth-config.controller.d.ts +22 -0
- package/controllers/social-auth.controller.d.ts +11 -0
- package/dtos/index.d.ts +1 -0
- package/dtos/social-auth.dto.d.ts +42 -0
- package/entities/index.d.ts +3 -1
- package/entities/social-auth-config.entity.d.ts +9 -0
- package/enums/index.d.ts +1 -0
- package/enums/social-provider-type.enum.d.ts +6 -0
- package/fesm/config/auth.constants.js +3 -0
- package/fesm/config/event-actions.js +4 -2
- package/fesm/config/message-keys.js +21 -0
- package/fesm/controllers/index.js +2 -0
- package/fesm/controllers/social-auth-config.controller.js +97 -0
- package/fesm/controllers/social-auth.controller.js +165 -0
- package/fesm/controllers/social-auth.controller.spec.js +75 -0
- package/fesm/docs/auth-swagger.config.js +9 -1
- package/fesm/dtos/index.js +1 -0
- package/fesm/dtos/social-auth.dto.js +282 -0
- package/fesm/entities/index.js +4 -1
- package/fesm/entities/index.spec.js +12 -1
- package/fesm/entities/social-auth-config.entity.js +92 -0
- package/fesm/enums/index.js +1 -0
- package/fesm/enums/social-provider-type.enum.js +7 -0
- package/fesm/index.js +1 -0
- package/fesm/interfaces/index.js +1 -0
- package/fesm/interfaces/social-auth.interface.js +1 -0
- package/fesm/modules/auth.module.js +8 -2
- package/fesm/modules/auth.module.spec.js +17 -2
- package/fesm/providers/facebook.social-provider.js +63 -0
- package/fesm/providers/google.social-provider.js +58 -0
- package/fesm/providers/index.js +1 -0
- package/fesm/providers/linkedin.social-provider.js +56 -0
- package/fesm/providers/microsoft.social-provider.js +68 -0
- package/fesm/providers/oauth-http.util.js +77 -0
- package/fesm/providers/social-provider.registry.js +14 -0
- package/fesm/providers/social-provider.registry.spec.js +131 -0
- package/fesm/services/auth-config.service.js +3 -0
- package/fesm/services/auth-config.service.spec.js +3 -0
- package/fesm/services/auth-datasource.provider.js +4 -0
- package/fesm/services/auth-datasource.provider.spec.js +30 -0
- package/fesm/services/authentication.service.js +38 -8
- package/fesm/services/authentication.service.spec.js +58 -0
- package/fesm/services/index.js +2 -0
- package/fesm/services/social-auth-config.service.js +93 -0
- package/fesm/services/social-auth-config.service.spec.js +67 -0
- package/fesm/services/social-auth.service.js +300 -0
- package/fesm/services/social-auth.service.spec.js +265 -0
- package/index.d.ts +1 -0
- package/interfaces/index.d.ts +1 -0
- package/interfaces/social-auth.interface.d.ts +43 -0
- package/package.json +3 -3
- package/providers/facebook.social-provider.d.ts +10 -0
- package/providers/google.social-provider.d.ts +10 -0
- package/providers/index.d.ts +1 -0
- package/providers/linkedin.social-provider.d.ts +10 -0
- package/providers/microsoft.social-provider.d.ts +10 -0
- package/providers/oauth-http.util.d.ts +10 -0
- package/providers/social-provider.registry.d.ts +3 -0
- package/services/auth-config.service.d.ts +1 -0
- package/services/auth-datasource.provider.d.ts +1 -0
- package/services/authentication.service.d.ts +4 -0
- package/services/index.d.ts +2 -0
- package/services/social-auth-config.service.d.ts +21 -0
- package/services/social-auth.service.d.ts +24 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { UnauthorizedException } from '@nestjs/common';
|
|
2
|
+
import { SocialProviderTypeEnum } from '../enums/social-provider-type.enum';
|
|
3
|
+
import { getSocialProvider } from './social-provider.registry';
|
|
4
|
+
const CREDENTIALS = {
|
|
5
|
+
clientId: 'client-id',
|
|
6
|
+
clientSecret: 'client-secret',
|
|
7
|
+
redirectUri: 'http://localhost:2001/auth/social/callback'
|
|
8
|
+
};
|
|
9
|
+
function mockFetchSequence(...responses) {
|
|
10
|
+
const fetchMock = jest.fn();
|
|
11
|
+
for (const response of responses){
|
|
12
|
+
fetchMock.mockResolvedValueOnce({
|
|
13
|
+
ok: response.ok ?? true,
|
|
14
|
+
status: response.status ?? 200,
|
|
15
|
+
text: async ()=>JSON.stringify(response.body)
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
globalThis.fetch = fetchMock;
|
|
19
|
+
return fetchMock;
|
|
20
|
+
}
|
|
21
|
+
describe('social providers', ()=>{
|
|
22
|
+
afterEach(()=>jest.restoreAllMocks());
|
|
23
|
+
describe('buildAuthorizationUrl', ()=>{
|
|
24
|
+
it.each(Object.values(SocialProviderTypeEnum))('carries the client id, redirect URI and state for %s', (provider)=>{
|
|
25
|
+
const url = new URL(getSocialProvider(provider).buildAuthorizationUrl(CREDENTIALS, 'state-token'));
|
|
26
|
+
expect(url.searchParams.get('client_id')).toBe('client-id');
|
|
27
|
+
expect(url.searchParams.get('redirect_uri')).toBe(CREDENTIALS.redirectUri);
|
|
28
|
+
expect(url.searchParams.get('state')).toBe('state-token');
|
|
29
|
+
expect(url.searchParams.get('scope')).toBeTruthy();
|
|
30
|
+
});
|
|
31
|
+
it('uses the configured scopes over the provider defaults', ()=>{
|
|
32
|
+
const url = new URL(getSocialProvider(SocialProviderTypeEnum.GOOGLE).buildAuthorizationUrl({
|
|
33
|
+
...CREDENTIALS,
|
|
34
|
+
scopes: [
|
|
35
|
+
'openid',
|
|
36
|
+
'email'
|
|
37
|
+
]
|
|
38
|
+
}, 'state-token'));
|
|
39
|
+
expect(url.searchParams.get('scope')).toBe('openid email');
|
|
40
|
+
});
|
|
41
|
+
it('targets the common Microsoft directory unless a tenant is configured', ()=>{
|
|
42
|
+
const provider = getSocialProvider(SocialProviderTypeEnum.MICROSOFT);
|
|
43
|
+
expect(provider.buildAuthorizationUrl(CREDENTIALS, 'state-token')).toContain('login.microsoftonline.com/common/');
|
|
44
|
+
expect(provider.buildAuthorizationUrl({
|
|
45
|
+
...CREDENTIALS,
|
|
46
|
+
extraConfig: {
|
|
47
|
+
tenantId: 'contoso'
|
|
48
|
+
}
|
|
49
|
+
}, 'state-token')).toContain('login.microsoftonline.com/contoso/');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe('fetchProfile', ()=>{
|
|
53
|
+
it('normalises a Google OpenID profile', async ()=>{
|
|
54
|
+
mockFetchSequence({
|
|
55
|
+
body: {
|
|
56
|
+
access_token: 'token'
|
|
57
|
+
}
|
|
58
|
+
}, {
|
|
59
|
+
body: {
|
|
60
|
+
sub: 'google-1',
|
|
61
|
+
email: 'user@example.com',
|
|
62
|
+
email_verified: true,
|
|
63
|
+
name: 'Test User',
|
|
64
|
+
picture: 'https://cdn.test/avatar.png'
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
const profile = await getSocialProvider(SocialProviderTypeEnum.GOOGLE).fetchProfile(CREDENTIALS, 'code');
|
|
68
|
+
expect(profile).toEqual({
|
|
69
|
+
providerUserId: 'google-1',
|
|
70
|
+
email: 'user@example.com',
|
|
71
|
+
emailVerified: true,
|
|
72
|
+
name: 'Test User',
|
|
73
|
+
avatarUrl: 'https://cdn.test/avatar.png'
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
it('reads the nested avatar Facebook returns', async ()=>{
|
|
77
|
+
mockFetchSequence({
|
|
78
|
+
body: {
|
|
79
|
+
access_token: 'token'
|
|
80
|
+
}
|
|
81
|
+
}, {
|
|
82
|
+
body: {
|
|
83
|
+
id: 'fb-1',
|
|
84
|
+
name: 'Test User',
|
|
85
|
+
email: 'user@example.com',
|
|
86
|
+
picture: {
|
|
87
|
+
data: {
|
|
88
|
+
url: 'https://cdn.test/fb.png'
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
const profile = await getSocialProvider(SocialProviderTypeEnum.FACEBOOK).fetchProfile(CREDENTIALS, 'code');
|
|
94
|
+
expect(profile.avatarUrl).toBe('https://cdn.test/fb.png');
|
|
95
|
+
expect(profile.emailVerified).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
it('falls back to the Microsoft principal name when no mailbox is set', async ()=>{
|
|
98
|
+
mockFetchSequence({
|
|
99
|
+
body: {
|
|
100
|
+
access_token: 'token'
|
|
101
|
+
}
|
|
102
|
+
}, {
|
|
103
|
+
body: {
|
|
104
|
+
id: 'ms-1',
|
|
105
|
+
displayName: 'Test User',
|
|
106
|
+
userPrincipalName: 'user@contoso.com'
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
const profile = await getSocialProvider(SocialProviderTypeEnum.MICROSOFT).fetchProfile(CREDENTIALS, 'code');
|
|
110
|
+
expect(profile.email).toBe('user@contoso.com');
|
|
111
|
+
});
|
|
112
|
+
it('rejects when the token endpoint fails', async ()=>{
|
|
113
|
+
mockFetchSequence({
|
|
114
|
+
ok: false,
|
|
115
|
+
status: 400,
|
|
116
|
+
body: {
|
|
117
|
+
error: 'invalid_grant'
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
await expect(getSocialProvider(SocialProviderTypeEnum.LINKEDIN).fetchProfile(CREDENTIALS, 'code')).rejects.toThrow(UnauthorizedException);
|
|
121
|
+
});
|
|
122
|
+
it('rejects when the token response carries no access token', async ()=>{
|
|
123
|
+
mockFetchSequence({
|
|
124
|
+
body: {
|
|
125
|
+
token_type: 'bearer'
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
await expect(getSocialProvider(SocialProviderTypeEnum.GOOGLE).fetchProfile(CREDENTIALS, 'code')).rejects.toThrow(UnauthorizedException);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -45,6 +45,9 @@ export class AuthConfigService {
|
|
|
45
45
|
isEmailVerificationEnabled() {
|
|
46
46
|
return this.options.bootstrapAppConfig?.enableEmailVerification ?? true;
|
|
47
47
|
}
|
|
48
|
+
isSignUpEnabled() {
|
|
49
|
+
return this.options.bootstrapAppConfig?.enableSignUp ?? true;
|
|
50
|
+
}
|
|
48
51
|
isPermissionModeFound() {
|
|
49
52
|
return this.options.bootstrapAppConfig?.permissionMode !== undefined;
|
|
50
53
|
}
|
|
@@ -17,6 +17,7 @@ describe('AuthConfigService', ()=>{
|
|
|
17
17
|
expect(service.isMultiTenant()).toBe(false);
|
|
18
18
|
expect(service.isCompanyFeatureEnabled()).toBe(false);
|
|
19
19
|
expect(service.isEmailVerificationEnabled()).toBe(true);
|
|
20
|
+
expect(service.isSignUpEnabled()).toBe(true);
|
|
20
21
|
expect(service.isPermissionModeFound()).toBe(false);
|
|
21
22
|
});
|
|
22
23
|
it('throws when reading the JWT secret without configuration', ()=>{
|
|
@@ -49,6 +50,7 @@ describe('AuthConfigService', ()=>{
|
|
|
49
50
|
databaseMode: 'multi-tenant',
|
|
50
51
|
enableCompanyFeature: true,
|
|
51
52
|
enableEmailVerification: false,
|
|
53
|
+
enableSignUp: false,
|
|
52
54
|
permissionMode: 'RBAC'
|
|
53
55
|
},
|
|
54
56
|
config: {
|
|
@@ -70,6 +72,7 @@ describe('AuthConfigService', ()=>{
|
|
|
70
72
|
const service = new AuthConfigService(buildOptions());
|
|
71
73
|
expect(service.isCompanyFeatureEnabled()).toBe(true);
|
|
72
74
|
expect(service.isEmailVerificationEnabled()).toBe(false);
|
|
75
|
+
expect(service.isSignUpEnabled()).toBe(false);
|
|
73
76
|
expect(service.isPermissionModeFound()).toBe(true);
|
|
74
77
|
});
|
|
75
78
|
it('returns configured JWT and refresh token values', ()=>{
|
|
@@ -48,6 +48,10 @@ export class AuthDataSourceProvider extends MultiTenantDataSourceService {
|
|
|
48
48
|
const effectiveTenant = tenant ?? this.getCurrentTenant();
|
|
49
49
|
return effectiveTenant?.enableEmailVerification ?? this.authOptions.bootstrapAppConfig?.enableEmailVerification ?? true;
|
|
50
50
|
}
|
|
51
|
+
isSignUpEnabled(tenant) {
|
|
52
|
+
const effectiveTenant = tenant ?? this.getCurrentTenant();
|
|
53
|
+
return effectiveTenant?.enableSignUp ?? this.authOptions.bootstrapAppConfig?.enableSignUp ?? true;
|
|
54
|
+
}
|
|
51
55
|
async getAuthEntities() {
|
|
52
56
|
const { getAuthEntitiesByConfig } = await import('../entities');
|
|
53
57
|
return getAuthEntitiesByConfig({
|
|
@@ -78,6 +78,36 @@ describe('AuthDataSourceProvider', ()=>{
|
|
|
78
78
|
})).toBe(false);
|
|
79
79
|
});
|
|
80
80
|
});
|
|
81
|
+
describe('isSignUpEnabled', ()=>{
|
|
82
|
+
it('returns true by default (sign-up defaults to enabled)', ()=>{
|
|
83
|
+
const provider = new AuthDataSourceProvider(buildOptions({
|
|
84
|
+
bootstrapAppConfig: undefined
|
|
85
|
+
}));
|
|
86
|
+
expect(provider.isSignUpEnabled()).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
it('returns false when bootstrap config disables it', ()=>{
|
|
89
|
+
const provider = new AuthDataSourceProvider(buildOptions({
|
|
90
|
+
bootstrapAppConfig: {
|
|
91
|
+
databaseMode: 'single',
|
|
92
|
+
enableCompanyFeature: false,
|
|
93
|
+
enableSignUp: false
|
|
94
|
+
}
|
|
95
|
+
}));
|
|
96
|
+
expect(provider.isSignUpEnabled()).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
it('prefers the tenant override over bootstrap config', ()=>{
|
|
99
|
+
const provider = new AuthDataSourceProvider(buildOptions({
|
|
100
|
+
bootstrapAppConfig: {
|
|
101
|
+
databaseMode: 'multi-tenant',
|
|
102
|
+
enableCompanyFeature: true,
|
|
103
|
+
enableSignUp: true
|
|
104
|
+
}
|
|
105
|
+
}));
|
|
106
|
+
expect(provider.isSignUpEnabled({
|
|
107
|
+
enableSignUp: false
|
|
108
|
+
})).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
81
111
|
describe('getAuthEntities', ()=>{
|
|
82
112
|
it('returns only base entities when company feature and email verification are disabled', async ()=>{
|
|
83
113
|
const provider = new AuthDataSourceProvider(buildOptions({
|
|
@@ -57,6 +57,9 @@ export class AuthenticationService {
|
|
|
57
57
|
const providerAvailable = this.authEmailService?.isEmailEnabled() ?? false;
|
|
58
58
|
return featureEnabled && providerAvailable;
|
|
59
59
|
}
|
|
60
|
+
isSignUpEnabled() {
|
|
61
|
+
return this.dataSourceProvider?.isSignUpEnabled() ?? this.authConfig.isSignUpEnabled();
|
|
62
|
+
}
|
|
60
63
|
async getUserRepository() {
|
|
61
64
|
if (!this.dataSourceProvider) {
|
|
62
65
|
throw new InternalServerErrorException({
|
|
@@ -83,12 +86,7 @@ export class AuthenticationService {
|
|
|
83
86
|
};
|
|
84
87
|
}
|
|
85
88
|
const user = await this.validateUser(email, password);
|
|
86
|
-
|
|
87
|
-
throw new ForbiddenException({
|
|
88
|
-
message: 'User account is deactivated',
|
|
89
|
-
messageKey: AUTH_MESSAGES.LOGIN_ACCOUNT_DEACTIVATED
|
|
90
|
-
});
|
|
91
|
-
}
|
|
89
|
+
this.ensureUserActive(user);
|
|
92
90
|
if (this.isEmailVerificationEnabled() && !user.emailVerified) {
|
|
93
91
|
return {
|
|
94
92
|
requiresEmailVerification: true,
|
|
@@ -97,11 +95,23 @@ export class AuthenticationService {
|
|
|
97
95
|
messageKey: AUTH_MESSAGES.LOGIN_EMAIL_NOT_VERIFIED
|
|
98
96
|
};
|
|
99
97
|
}
|
|
98
|
+
return this.issueLoginForUser(user);
|
|
99
|
+
}
|
|
100
|
+
async issueLoginForUser(user) {
|
|
101
|
+
this.ensureUserActive(user);
|
|
100
102
|
if (!this.isCompanyFeatureEnabled()) {
|
|
101
103
|
return this.generateLoginResponse(user);
|
|
102
104
|
}
|
|
103
105
|
return await this.handleCompanyBasedLogin(user);
|
|
104
106
|
}
|
|
107
|
+
ensureUserActive(user) {
|
|
108
|
+
if (!user.isActive) {
|
|
109
|
+
throw new ForbiddenException({
|
|
110
|
+
message: 'User account is deactivated',
|
|
111
|
+
messageKey: AUTH_MESSAGES.LOGIN_ACCOUNT_DEACTIVATED
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
105
115
|
async handleCompanyBasedLogin(user) {
|
|
106
116
|
const permissionRepository = await this.getCompanyScopedRepository(UserCompanyPermission);
|
|
107
117
|
const branchRepository = await this.getCompanyScopedRepository(CompanyBranch);
|
|
@@ -149,8 +159,15 @@ export class AuthenticationService {
|
|
|
149
159
|
const existingUser = await userRepository.findOne({
|
|
150
160
|
where: {
|
|
151
161
|
email: dto.email
|
|
152
|
-
}
|
|
162
|
+
},
|
|
163
|
+
withDeleted: true
|
|
153
164
|
});
|
|
165
|
+
if (existingUser?.deletedAt) {
|
|
166
|
+
throw new BadRequestException({
|
|
167
|
+
message: 'This account has been permanently deleted. Please contact your system administrator.',
|
|
168
|
+
messageKey: AUTH_MESSAGES.REGISTER_ACCOUNT_DELETED
|
|
169
|
+
});
|
|
170
|
+
}
|
|
154
171
|
if (existingUser && this.isCompanyFeatureEnabled()) {
|
|
155
172
|
return await this.handleExistingUserCompanyRegistration(existingUser, dto);
|
|
156
173
|
}
|
|
@@ -160,6 +177,12 @@ export class AuthenticationService {
|
|
|
160
177
|
messageKey: AUTH_MESSAGES.REGISTER_EMAIL_EXISTS
|
|
161
178
|
});
|
|
162
179
|
}
|
|
180
|
+
if (!this.isSignUpEnabled()) {
|
|
181
|
+
throw new BadRequestException({
|
|
182
|
+
message: 'New account registration is currently disabled.',
|
|
183
|
+
messageKey: AUTH_MESSAGES.REGISTER_SIGNUP_DISABLED
|
|
184
|
+
});
|
|
185
|
+
}
|
|
163
186
|
if (this.isEmailVerificationEnabled() && verifyUrlBase) {
|
|
164
187
|
const companyData = this.isCompanyFeatureEnabled() ? {
|
|
165
188
|
companySlug: dto.companySlug,
|
|
@@ -287,8 +310,15 @@ export class AuthenticationService {
|
|
|
287
310
|
const existingUser = await queryRunner.manager.findOne(AppUser, {
|
|
288
311
|
where: {
|
|
289
312
|
email: pending.email
|
|
290
|
-
}
|
|
313
|
+
},
|
|
314
|
+
withDeleted: true
|
|
291
315
|
});
|
|
316
|
+
if (existingUser?.deletedAt) {
|
|
317
|
+
throw new BadRequestException({
|
|
318
|
+
message: 'This account has been permanently deleted. Please contact your system administrator.',
|
|
319
|
+
messageKey: AUTH_MESSAGES.REGISTER_ACCOUNT_DELETED
|
|
320
|
+
});
|
|
321
|
+
}
|
|
292
322
|
if (existingUser) {
|
|
293
323
|
throw new BadRequestException({
|
|
294
324
|
message: 'This email has already been registered.',
|
|
@@ -86,6 +86,7 @@ describe('AuthenticationService', ()=>{
|
|
|
86
86
|
let mockUserEnricher;
|
|
87
87
|
let companyFeatureEnabled;
|
|
88
88
|
let emailVerificationEnabled;
|
|
89
|
+
let signUpEnabled;
|
|
89
90
|
function buildService(overrides = {}) {
|
|
90
91
|
return new AuthenticationService(mockJwtService, mockAuthConfig, 'cacheManager' in overrides ? overrides.cacheManager : mockCacheManager, 'dataSourceProvider' in overrides ? overrides.dataSourceProvider : mockDataSourceProvider, 'sessionService' in overrides ? overrides.sessionService : mockSessionService, 'authEmailService' in overrides ? overrides.authEmailService : mockAuthEmailService, 'userEnricher' in overrides ? overrides.userEnricher : mockUserEnricher, 'notificationAdapter' in overrides ? overrides.notificationAdapter : undefined);
|
|
91
92
|
}
|
|
@@ -102,6 +103,7 @@ describe('AuthenticationService', ()=>{
|
|
|
102
103
|
});
|
|
103
104
|
companyFeatureEnabled = false;
|
|
104
105
|
emailVerificationEnabled = false;
|
|
106
|
+
signUpEnabled = true;
|
|
105
107
|
mockJwtService = {
|
|
106
108
|
sign: jest.fn().mockReturnValue('signed-token'),
|
|
107
109
|
verify: jest.fn()
|
|
@@ -109,6 +111,7 @@ describe('AuthenticationService', ()=>{
|
|
|
109
111
|
mockAuthConfig = {
|
|
110
112
|
isCompanyFeatureEnabled: jest.fn().mockImplementation(()=>companyFeatureEnabled),
|
|
111
113
|
isEmailVerificationEnabled: jest.fn().mockImplementation(()=>emailVerificationEnabled),
|
|
114
|
+
isSignUpEnabled: jest.fn().mockImplementation(()=>signUpEnabled),
|
|
112
115
|
getJwtSecret: jest.fn().mockReturnValue('jwt-secret'),
|
|
113
116
|
getJwtExpiration: jest.fn().mockReturnValue('1h'),
|
|
114
117
|
getRefreshTokenSecret: jest.fn().mockReturnValue('refresh-secret'),
|
|
@@ -122,6 +125,7 @@ describe('AuthenticationService', ()=>{
|
|
|
122
125
|
mockDataSourceProvider = {
|
|
123
126
|
isCompanyFeatureEnabled: jest.fn().mockImplementation(()=>companyFeatureEnabled),
|
|
124
127
|
isEmailVerificationEnabled: jest.fn().mockImplementation(()=>emailVerificationEnabled),
|
|
128
|
+
isSignUpEnabled: jest.fn().mockImplementation(()=>signUpEnabled),
|
|
125
129
|
getRepository: jest.fn().mockImplementation(async (entity)=>{
|
|
126
130
|
if (entity === AppUser) return userRepo;
|
|
127
131
|
if (entity === Company) return companyRepo;
|
|
@@ -296,6 +300,20 @@ describe('AuthenticationService', ()=>{
|
|
|
296
300
|
password: 'Password1!'
|
|
297
301
|
})).rejects.toThrow(BadRequestException);
|
|
298
302
|
});
|
|
303
|
+
it('throws BadRequestException when the email belongs to a permanently deleted account', async ()=>{
|
|
304
|
+
userRepo.findOne.mockResolvedValue(buildUser({
|
|
305
|
+
deletedAt: new Date()
|
|
306
|
+
}));
|
|
307
|
+
const service = buildService();
|
|
308
|
+
await expect(service.register({
|
|
309
|
+
email: 'user@example.com',
|
|
310
|
+
name: 'Test',
|
|
311
|
+
password: 'Password1!'
|
|
312
|
+
})).rejects.toThrow(BadRequestException);
|
|
313
|
+
expect(userRepo.findOne).toHaveBeenCalledWith(expect.objectContaining({
|
|
314
|
+
withDeleted: true
|
|
315
|
+
}));
|
|
316
|
+
});
|
|
299
317
|
it('routes existing users to company registration when the company feature is enabled', async ()=>{
|
|
300
318
|
companyFeatureEnabled = true;
|
|
301
319
|
userRepo.findOne.mockResolvedValue(buildUser());
|
|
@@ -317,6 +335,16 @@ describe('AuthenticationService', ()=>{
|
|
|
317
335
|
accessToken: expect.any(String)
|
|
318
336
|
}));
|
|
319
337
|
});
|
|
338
|
+
it('throws BadRequestException for a new user when sign-up is disabled', async ()=>{
|
|
339
|
+
signUpEnabled = false;
|
|
340
|
+
userRepo.findOne.mockResolvedValue(null);
|
|
341
|
+
const service = buildService();
|
|
342
|
+
await expect(service.register({
|
|
343
|
+
email: 'new@example.com',
|
|
344
|
+
name: 'New User',
|
|
345
|
+
password: 'Password1!'
|
|
346
|
+
})).rejects.toThrow(BadRequestException);
|
|
347
|
+
});
|
|
320
348
|
it('sends a verification email and returns requiresEmailVerification when email verification is enabled', async ()=>{
|
|
321
349
|
emailVerificationEnabled = true;
|
|
322
350
|
userRepo.findOne.mockResolvedValue(null);
|
|
@@ -823,6 +851,36 @@ describe('AuthenticationService', ()=>{
|
|
|
823
851
|
})).rejects.toThrow(BadRequestException);
|
|
824
852
|
expect(queryRunner.rollbackTransaction).toHaveBeenCalled();
|
|
825
853
|
});
|
|
854
|
+
it('throws BadRequestException when the email belongs to a permanently deleted account', async ()=>{
|
|
855
|
+
const queryRunner = {
|
|
856
|
+
connect: jest.fn(),
|
|
857
|
+
startTransaction: jest.fn(),
|
|
858
|
+
commitTransaction: jest.fn(),
|
|
859
|
+
rollbackTransaction: jest.fn(),
|
|
860
|
+
release: jest.fn(),
|
|
861
|
+
manager: {
|
|
862
|
+
findOne: jest.fn().mockResolvedValue(buildUser({
|
|
863
|
+
deletedAt: new Date()
|
|
864
|
+
})),
|
|
865
|
+
create: jest.fn(),
|
|
866
|
+
save: jest.fn()
|
|
867
|
+
}
|
|
868
|
+
};
|
|
869
|
+
mockDataSourceProvider.getDataSource.mockResolvedValue({
|
|
870
|
+
createQueryRunner: ()=>queryRunner
|
|
871
|
+
});
|
|
872
|
+
const service = buildService();
|
|
873
|
+
await expect(service.createUserFromPendingRegistration({
|
|
874
|
+
email: 'user@example.com',
|
|
875
|
+
name: 'Test',
|
|
876
|
+
passwordHash: 'hash'
|
|
877
|
+
})).rejects.toThrow(BadRequestException);
|
|
878
|
+
expect(queryRunner.manager.findOne).toHaveBeenCalledWith(AppUser, expect.objectContaining({
|
|
879
|
+
withDeleted: true
|
|
880
|
+
}));
|
|
881
|
+
expect(queryRunner.manager.create).not.toHaveBeenCalled();
|
|
882
|
+
expect(queryRunner.rollbackTransaction).toHaveBeenCalled();
|
|
883
|
+
});
|
|
826
884
|
it('creates the user with the email marked as verified', async ()=>{
|
|
827
885
|
const queryRunner = {
|
|
828
886
|
connect: jest.fn(),
|
package/fesm/services/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export * from './authentication.service';
|
|
2
2
|
export * from './auth-email.service';
|
|
3
3
|
export * from './company-selection-session.service';
|
|
4
|
+
export * from './social-auth.service';
|
|
5
|
+
export * from './social-auth-config.service';
|
|
4
6
|
export * from './user.service';
|
|
5
7
|
export * from './company.service';
|
|
6
8
|
export * from './branch.service';
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
function _define_property(obj, key, value) {
|
|
2
|
+
if (key in obj) {
|
|
3
|
+
Object.defineProperty(obj, key, {
|
|
4
|
+
value: value,
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true
|
|
8
|
+
});
|
|
9
|
+
} else {
|
|
10
|
+
obj[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return obj;
|
|
13
|
+
}
|
|
14
|
+
function _ts_decorate(decorators, target, key, desc) {
|
|
15
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
16
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
17
|
+
else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
18
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
19
|
+
}
|
|
20
|
+
function _ts_metadata(k, v) {
|
|
21
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
22
|
+
}
|
|
23
|
+
function _ts_param(paramIndex, decorator) {
|
|
24
|
+
return function(target, key) {
|
|
25
|
+
decorator(target, key, paramIndex);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
|
|
29
|
+
import { UtilsService } from '@flusys/nestjs-shared/modules';
|
|
30
|
+
import { ConflictException, Inject, Injectable, Scope } from '@nestjs/common';
|
|
31
|
+
import { IsNull, Not } from 'typeorm';
|
|
32
|
+
import { AUTH_EVENT_ENTITIES, AUTH_EVENT_MODULE, SOCIAL_AUTH_CONFIG_MESSAGES } from '../config';
|
|
33
|
+
import { SocialAuthConfig } from '../entities/social-auth-config.entity';
|
|
34
|
+
import { AuthDataSourceProvider } from './auth-datasource.provider';
|
|
35
|
+
export class SocialAuthConfigService extends ApiService {
|
|
36
|
+
async beforeInsertOperation(dto, _user, _queryRunner) {
|
|
37
|
+
for (const entry of Array.isArray(dto) ? dto : [
|
|
38
|
+
dto
|
|
39
|
+
]){
|
|
40
|
+
await this.ensureProviderUnclaimed(entry.provider);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async beforeUpdateOperation(dto, _user, _queryRunner) {
|
|
44
|
+
for (const entry of Array.isArray(dto) ? dto : [
|
|
45
|
+
dto
|
|
46
|
+
]){
|
|
47
|
+
if (entry.provider) await this.ensureProviderUnclaimed(entry.provider, entry.id);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async ensureProviderUnclaimed(provider, exceptId) {
|
|
51
|
+
await this.ensureDataSourceRepository();
|
|
52
|
+
const existing = await this.repository.findOne({
|
|
53
|
+
where: {
|
|
54
|
+
provider,
|
|
55
|
+
deletedAt: IsNull(),
|
|
56
|
+
...exceptId ? {
|
|
57
|
+
id: Not(exceptId)
|
|
58
|
+
} : {}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
if (existing) {
|
|
62
|
+
throw new ConflictException({
|
|
63
|
+
message: `Social provider "${provider}" is already configured`,
|
|
64
|
+
messageKey: SOCIAL_AUTH_CONFIG_MESSAGES.PROVIDER_ALREADY_CONFIGURED,
|
|
65
|
+
messageVariables: {
|
|
66
|
+
provider
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async getExtraManipulateQuery(query, filterDto, user) {
|
|
72
|
+
const result = await super.getExtraManipulateQuery(query, filterDto, user);
|
|
73
|
+
query.orderBy(`${this.entityName}.provider`, 'ASC');
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
constructor(cacheManager, utilsService, dataSourceProvider){
|
|
77
|
+
super(AUTH_EVENT_ENTITIES.SOCIAL_AUTH_CONFIG, cacheManager, utilsService, SocialAuthConfigService.name, true, AUTH_EVENT_MODULE, dataSourceProvider, SocialAuthConfig), _define_property(this, "cacheManager", void 0), _define_property(this, "utilsService", void 0), this.cacheManager = cacheManager, this.utilsService = utilsService;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
SocialAuthConfigService = _ts_decorate([
|
|
81
|
+
Injectable({
|
|
82
|
+
scope: Scope.REQUEST
|
|
83
|
+
}),
|
|
84
|
+
_ts_param(0, Inject('CACHE_INSTANCE')),
|
|
85
|
+
_ts_param(1, Inject(UtilsService)),
|
|
86
|
+
_ts_param(2, Inject(AuthDataSourceProvider)),
|
|
87
|
+
_ts_metadata("design:type", Function),
|
|
88
|
+
_ts_metadata("design:paramtypes", [
|
|
89
|
+
typeof HybridCache === "undefined" ? Object : HybridCache,
|
|
90
|
+
typeof UtilsService === "undefined" ? Object : UtilsService,
|
|
91
|
+
typeof AuthDataSourceProvider === "undefined" ? Object : AuthDataSourceProvider
|
|
92
|
+
])
|
|
93
|
+
], SocialAuthConfigService);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ConflictException } from '@nestjs/common';
|
|
2
|
+
import { createMockRepository } from '@test-utils/mocks/repository.mock';
|
|
3
|
+
import { Not } from 'typeorm';
|
|
4
|
+
import { SocialProviderTypeEnum } from '../enums/social-provider-type.enum';
|
|
5
|
+
import { SocialAuthConfigService } from './social-auth-config.service';
|
|
6
|
+
describe('SocialAuthConfigService', ()=>{
|
|
7
|
+
let repo;
|
|
8
|
+
let service;
|
|
9
|
+
beforeEach(()=>{
|
|
10
|
+
repo = createMockRepository();
|
|
11
|
+
const dataSourceProvider = {
|
|
12
|
+
getRepository: jest.fn().mockResolvedValue(repo)
|
|
13
|
+
};
|
|
14
|
+
service = new SocialAuthConfigService({
|
|
15
|
+
get: jest.fn(),
|
|
16
|
+
set: jest.fn(),
|
|
17
|
+
del: jest.fn()
|
|
18
|
+
}, {}, dataSourceProvider);
|
|
19
|
+
});
|
|
20
|
+
const beforeInsert = (dto)=>service.beforeInsertOperation(dto, null, {});
|
|
21
|
+
const beforeUpdate = (dto)=>service.beforeUpdateOperation(dto, null, {});
|
|
22
|
+
it('allows the first configuration of a provider', async ()=>{
|
|
23
|
+
repo.findOne.mockResolvedValue(null);
|
|
24
|
+
await expect(beforeInsert({
|
|
25
|
+
provider: SocialProviderTypeEnum.GOOGLE
|
|
26
|
+
})).resolves.toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
it('rejects a second row for a provider that is already configured', async ()=>{
|
|
29
|
+
repo.findOne.mockResolvedValue({
|
|
30
|
+
id: 'existing'
|
|
31
|
+
});
|
|
32
|
+
await expect(beforeInsert({
|
|
33
|
+
provider: SocialProviderTypeEnum.GOOGLE
|
|
34
|
+
})).rejects.toThrow(ConflictException);
|
|
35
|
+
});
|
|
36
|
+
it('checks every provider in a batch insert', async ()=>{
|
|
37
|
+
repo.findOne.mockResolvedValue(null);
|
|
38
|
+
await beforeInsert([
|
|
39
|
+
{
|
|
40
|
+
provider: SocialProviderTypeEnum.GOOGLE
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
provider: SocialProviderTypeEnum.FACEBOOK
|
|
44
|
+
}
|
|
45
|
+
]);
|
|
46
|
+
expect(repo.findOne).toHaveBeenCalledTimes(2);
|
|
47
|
+
});
|
|
48
|
+
it('lets a row keep its own provider on update', async ()=>{
|
|
49
|
+
repo.findOne.mockResolvedValue(null);
|
|
50
|
+
await beforeUpdate({
|
|
51
|
+
id: 'config-1',
|
|
52
|
+
provider: SocialProviderTypeEnum.GOOGLE
|
|
53
|
+
});
|
|
54
|
+
expect(repo.findOne).toHaveBeenCalledWith(expect.objectContaining({
|
|
55
|
+
where: expect.objectContaining({
|
|
56
|
+
id: Not('config-1')
|
|
57
|
+
})
|
|
58
|
+
}));
|
|
59
|
+
});
|
|
60
|
+
it('skips the check for an update that does not touch the provider', async ()=>{
|
|
61
|
+
await beforeUpdate({
|
|
62
|
+
id: 'config-1',
|
|
63
|
+
isEnabled: false
|
|
64
|
+
});
|
|
65
|
+
expect(repo.findOne).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
});
|