@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,300 @@
|
|
|
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 { LogAction } from '@flusys/nestjs-shared';
|
|
29
|
+
import { HybridCache, publishDomainEvent } from '@flusys/nestjs-shared/classes';
|
|
30
|
+
import { SYSTEM_MESSAGES } from '@flusys/nestjs-shared/constants';
|
|
31
|
+
import { BadRequestException, Inject, Injectable, InternalServerErrorException, Optional, Scope, UnauthorizedException } from '@nestjs/common';
|
|
32
|
+
import { randomBytes } from 'crypto';
|
|
33
|
+
import { IsNull } from 'typeorm';
|
|
34
|
+
import { AUTH_EVENT_ACTIONS, AUTH_EVENT_ENTITIES, AUTH_EVENT_MODULE, SOCIAL_AUTH_MESSAGES, SOCIAL_STATE_PREFIX, SOCIAL_STATE_TTL_MS } from '../config';
|
|
35
|
+
import { AppUser } from '../entities/app-user.entity';
|
|
36
|
+
import { SocialAuthConfig } from '../entities/social-auth-config.entity';
|
|
37
|
+
import { SocialProviderTypeEnum } from '../enums/social-provider-type.enum';
|
|
38
|
+
import { getSocialProvider } from '../providers/social-provider.registry';
|
|
39
|
+
import { AuthDataSourceProvider } from './auth-datasource.provider';
|
|
40
|
+
import { AuthenticationService } from './authentication.service';
|
|
41
|
+
export class SocialAuthService {
|
|
42
|
+
async listEnabledProviders(redirectUri) {
|
|
43
|
+
const repository = await this.dataSourceProvider.getRepository(SocialAuthConfig);
|
|
44
|
+
const configs = await repository.find({
|
|
45
|
+
where: {
|
|
46
|
+
isEnabled: true,
|
|
47
|
+
deletedAt: IsNull()
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
return configs.filter((config)=>!!config.clientId && !!config.clientSecret).map((config)=>({
|
|
51
|
+
provider: config.provider,
|
|
52
|
+
redirectUri
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
async createAuthorization(provider, redirectUri) {
|
|
56
|
+
const config = await this.getUsableConfig(provider);
|
|
57
|
+
const state = randomBytes(24).toString('hex');
|
|
58
|
+
await this.getCache().set(this.stateKey(state), {
|
|
59
|
+
provider,
|
|
60
|
+
redirectUri
|
|
61
|
+
}, SOCIAL_STATE_TTL_MS);
|
|
62
|
+
return {
|
|
63
|
+
provider,
|
|
64
|
+
authorizationUrl: getSocialProvider(provider).buildAuthorizationUrl(this.toCredentials(config, redirectUri), state),
|
|
65
|
+
state
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async completeLogin(code, state, redirectUri) {
|
|
69
|
+
const provider = await this.consumeState(state, redirectUri);
|
|
70
|
+
const config = await this.getUsableConfig(provider);
|
|
71
|
+
const profile = await getSocialProvider(provider).fetchProfile(this.toCredentials(config, redirectUri), code);
|
|
72
|
+
if (!profile.providerUserId) {
|
|
73
|
+
throw new UnauthorizedException({
|
|
74
|
+
message: `${provider} did not return an account identifier`,
|
|
75
|
+
messageKey: SOCIAL_AUTH_MESSAGES.PROFILE_INCOMPLETE,
|
|
76
|
+
messageVariables: {
|
|
77
|
+
provider
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const user = await this.resolveUser(provider, profile);
|
|
82
|
+
const response = await this.authService.issueLoginForUser(user);
|
|
83
|
+
this.publishSocialEvent(AUTH_EVENT_ENTITIES.SESSION, AUTH_EVENT_ACTIONS.SOCIAL_LOGGED_IN, user, provider);
|
|
84
|
+
return response;
|
|
85
|
+
}
|
|
86
|
+
async resolveUser(provider, profile) {
|
|
87
|
+
this.ensureVerifiedEmail(provider, profile);
|
|
88
|
+
const userRepository = await this.dataSourceProvider.getRepository(AppUser);
|
|
89
|
+
const existing = await userRepository.findOne({
|
|
90
|
+
where: {
|
|
91
|
+
email: profile.email
|
|
92
|
+
},
|
|
93
|
+
withDeleted: true
|
|
94
|
+
});
|
|
95
|
+
if (existing?.deletedAt) {
|
|
96
|
+
throw new UnauthorizedException({
|
|
97
|
+
message: 'This account has been permanently deleted. Please contact your system administrator.',
|
|
98
|
+
messageKey: SOCIAL_AUTH_MESSAGES.ACCOUNT_DELETED
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (existing) {
|
|
102
|
+
// The provider already proved ownership of this email (ensureVerifiedEmail above),
|
|
103
|
+
// so a social login carries at least as much proof as clicking a verification link.
|
|
104
|
+
if (!existing.emailVerified) {
|
|
105
|
+
existing.emailVerified = true;
|
|
106
|
+
await userRepository.save(existing);
|
|
107
|
+
}
|
|
108
|
+
return existing;
|
|
109
|
+
}
|
|
110
|
+
const user = await this.registerUser(provider, userRepository, profile);
|
|
111
|
+
this.publishSocialEvent(AUTH_EVENT_ENTITIES.USER, AUTH_EVENT_ACTIONS.REGISTERED, user, provider);
|
|
112
|
+
return user;
|
|
113
|
+
}
|
|
114
|
+
ensureVerifiedEmail(provider, profile) {
|
|
115
|
+
if (!profile.email) {
|
|
116
|
+
throw new UnauthorizedException({
|
|
117
|
+
message: `${provider} did not share an email address`,
|
|
118
|
+
messageKey: SOCIAL_AUTH_MESSAGES.EMAIL_REQUIRED,
|
|
119
|
+
messageVariables: {
|
|
120
|
+
provider
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (!profile.emailVerified) {
|
|
125
|
+
throw new UnauthorizedException({
|
|
126
|
+
message: `${provider} has not verified this email address`,
|
|
127
|
+
messageKey: SOCIAL_AUTH_MESSAGES.EMAIL_NOT_VERIFIED,
|
|
128
|
+
messageVariables: {
|
|
129
|
+
provider
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async registerUser(provider, userRepository, profile) {
|
|
135
|
+
if (!this.dataSourceProvider.isSignUpEnabled()) {
|
|
136
|
+
throw new UnauthorizedException({
|
|
137
|
+
message: `No account is linked to this ${provider} profile`,
|
|
138
|
+
messageKey: SOCIAL_AUTH_MESSAGES.ACCOUNT_NOT_LINKED,
|
|
139
|
+
messageVariables: {
|
|
140
|
+
provider
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (this.dataSourceProvider.isCompanyFeatureEnabled()) {
|
|
145
|
+
throw new UnauthorizedException({
|
|
146
|
+
message: `No account is linked to this ${provider} profile. Please sign up manually and join or create a company first, then social sign-in will work.`,
|
|
147
|
+
messageKey: SOCIAL_AUTH_MESSAGES.COMPANY_REQUIRED,
|
|
148
|
+
messageVariables: {
|
|
149
|
+
provider
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return userRepository.save(userRepository.create({
|
|
154
|
+
email: profile.email,
|
|
155
|
+
name: profile.name,
|
|
156
|
+
password: null,
|
|
157
|
+
isActive: true,
|
|
158
|
+
emailVerified: true
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
async getUsableConfig(provider) {
|
|
162
|
+
const repository = await this.dataSourceProvider.getRepository(SocialAuthConfig);
|
|
163
|
+
const config = await repository.findOne({
|
|
164
|
+
where: {
|
|
165
|
+
provider,
|
|
166
|
+
deletedAt: IsNull()
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
if (!config?.isEnabled) {
|
|
170
|
+
throw new BadRequestException({
|
|
171
|
+
message: `Social sign-in with ${provider} is not enabled`,
|
|
172
|
+
messageKey: SOCIAL_AUTH_MESSAGES.PROVIDER_DISABLED,
|
|
173
|
+
messageVariables: {
|
|
174
|
+
provider
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (!config.clientId || !config.clientSecret) {
|
|
179
|
+
throw new BadRequestException({
|
|
180
|
+
message: `Social sign-in with ${provider} is missing its client credentials`,
|
|
181
|
+
messageKey: SOCIAL_AUTH_MESSAGES.PROVIDER_NOT_CONFIGURED,
|
|
182
|
+
messageVariables: {
|
|
183
|
+
provider
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return config;
|
|
188
|
+
}
|
|
189
|
+
toCredentials(config, redirectUri) {
|
|
190
|
+
return {
|
|
191
|
+
clientId: config.clientId,
|
|
192
|
+
clientSecret: config.clientSecret,
|
|
193
|
+
redirectUri,
|
|
194
|
+
scopes: config.scopes,
|
|
195
|
+
extraConfig: config.extraConfig
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
async consumeState(state, redirectUri) {
|
|
199
|
+
const key = this.stateKey(state);
|
|
200
|
+
const stored = await this.getCache().get(key);
|
|
201
|
+
await this.getCache().del(key);
|
|
202
|
+
if (!stored) {
|
|
203
|
+
throw new UnauthorizedException({
|
|
204
|
+
message: 'Social sign-in session is invalid or has expired',
|
|
205
|
+
messageKey: SOCIAL_AUTH_MESSAGES.STATE_INVALID
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (stored.redirectUri !== redirectUri) {
|
|
209
|
+
throw new UnauthorizedException({
|
|
210
|
+
message: 'Redirect URI does not match the one the sign-in started with',
|
|
211
|
+
messageKey: SOCIAL_AUTH_MESSAGES.REDIRECT_URI_MISMATCH
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return stored.provider;
|
|
215
|
+
}
|
|
216
|
+
stateKey(state) {
|
|
217
|
+
return `${SOCIAL_STATE_PREFIX}:${state}`;
|
|
218
|
+
}
|
|
219
|
+
getCache() {
|
|
220
|
+
if (!this.cacheManager) {
|
|
221
|
+
throw new InternalServerErrorException({
|
|
222
|
+
message: 'Cache is required to hold the social sign-in state',
|
|
223
|
+
messageKey: SYSTEM_MESSAGES.SERVICE_NOT_AVAILABLE,
|
|
224
|
+
messageVariables: {
|
|
225
|
+
provider: 'CACHE_INSTANCE',
|
|
226
|
+
available: ''
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
return this.cacheManager;
|
|
231
|
+
}
|
|
232
|
+
publishSocialEvent(entity, action, user, provider) {
|
|
233
|
+
void publishDomainEvent({
|
|
234
|
+
module: AUTH_EVENT_MODULE,
|
|
235
|
+
entity,
|
|
236
|
+
action,
|
|
237
|
+
ids: [
|
|
238
|
+
user.id
|
|
239
|
+
],
|
|
240
|
+
metadata: {
|
|
241
|
+
email: user.email,
|
|
242
|
+
provider
|
|
243
|
+
},
|
|
244
|
+
user: {
|
|
245
|
+
id: user.id,
|
|
246
|
+
email: user.email
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
constructor(authService, dataSourceProvider, cacheManager){
|
|
251
|
+
_define_property(this, "authService", void 0);
|
|
252
|
+
_define_property(this, "dataSourceProvider", void 0);
|
|
253
|
+
_define_property(this, "cacheManager", void 0);
|
|
254
|
+
this.authService = authService;
|
|
255
|
+
this.dataSourceProvider = dataSourceProvider;
|
|
256
|
+
this.cacheManager = cacheManager;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
_ts_decorate([
|
|
260
|
+
LogAction({
|
|
261
|
+
action: 'auth.social.authorize',
|
|
262
|
+
module: 'auth',
|
|
263
|
+
includeParams: false
|
|
264
|
+
}),
|
|
265
|
+
_ts_metadata("design:type", Function),
|
|
266
|
+
_ts_metadata("design:paramtypes", [
|
|
267
|
+
typeof SocialProviderTypeEnum === "undefined" ? Object : SocialProviderTypeEnum,
|
|
268
|
+
String
|
|
269
|
+
]),
|
|
270
|
+
_ts_metadata("design:returntype", Promise)
|
|
271
|
+
], SocialAuthService.prototype, "createAuthorization", null);
|
|
272
|
+
_ts_decorate([
|
|
273
|
+
LogAction({
|
|
274
|
+
action: 'auth.social.login',
|
|
275
|
+
module: 'auth',
|
|
276
|
+
includeParams: false
|
|
277
|
+
}),
|
|
278
|
+
_ts_metadata("design:type", Function),
|
|
279
|
+
_ts_metadata("design:paramtypes", [
|
|
280
|
+
String,
|
|
281
|
+
String,
|
|
282
|
+
String
|
|
283
|
+
]),
|
|
284
|
+
_ts_metadata("design:returntype", Promise)
|
|
285
|
+
], SocialAuthService.prototype, "completeLogin", null);
|
|
286
|
+
SocialAuthService = _ts_decorate([
|
|
287
|
+
Injectable({
|
|
288
|
+
scope: Scope.REQUEST
|
|
289
|
+
}),
|
|
290
|
+
_ts_param(0, Inject(AuthenticationService)),
|
|
291
|
+
_ts_param(1, Inject(AuthDataSourceProvider)),
|
|
292
|
+
_ts_param(2, Optional()),
|
|
293
|
+
_ts_param(2, Inject('CACHE_INSTANCE')),
|
|
294
|
+
_ts_metadata("design:type", Function),
|
|
295
|
+
_ts_metadata("design:paramtypes", [
|
|
296
|
+
typeof AuthenticationService === "undefined" ? Object : AuthenticationService,
|
|
297
|
+
typeof AuthDataSourceProvider === "undefined" ? Object : AuthDataSourceProvider,
|
|
298
|
+
typeof HybridCache === "undefined" ? Object : HybridCache
|
|
299
|
+
])
|
|
300
|
+
], SocialAuthService);
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { createMockRepository } from '@test-utils/mocks/repository.mock';
|
|
2
|
+
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
|
3
|
+
import { AppUser } from '../entities/app-user.entity';
|
|
4
|
+
import { SocialAuthConfig } from '../entities/social-auth-config.entity';
|
|
5
|
+
import { SocialProviderTypeEnum } from '../enums/social-provider-type.enum';
|
|
6
|
+
import * as registry from '../providers/social-provider.registry';
|
|
7
|
+
import { SocialAuthService } from './social-auth.service';
|
|
8
|
+
const REDIRECT_URI = 'http://localhost:2001/auth/social/callback';
|
|
9
|
+
function buildConfig(overrides = {}) {
|
|
10
|
+
return {
|
|
11
|
+
id: 'config-1',
|
|
12
|
+
provider: SocialProviderTypeEnum.GOOGLE,
|
|
13
|
+
isEnabled: true,
|
|
14
|
+
clientId: 'client-id',
|
|
15
|
+
clientSecret: 'client-secret',
|
|
16
|
+
scopes: null,
|
|
17
|
+
extraConfig: null,
|
|
18
|
+
createdAt: new Date(),
|
|
19
|
+
updatedAt: new Date(),
|
|
20
|
+
deletedAt: null,
|
|
21
|
+
createdById: null,
|
|
22
|
+
updatedById: null,
|
|
23
|
+
deletedById: null,
|
|
24
|
+
...overrides
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function buildProfile(overrides = {}) {
|
|
28
|
+
return {
|
|
29
|
+
providerUserId: 'google-user-1',
|
|
30
|
+
email: 'user@example.com',
|
|
31
|
+
emailVerified: true,
|
|
32
|
+
name: 'Test User',
|
|
33
|
+
avatarUrl: null,
|
|
34
|
+
...overrides
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function buildUser(overrides = {}) {
|
|
38
|
+
return {
|
|
39
|
+
id: 'user-1',
|
|
40
|
+
email: 'user@example.com',
|
|
41
|
+
isActive: true,
|
|
42
|
+
...overrides
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
describe('SocialAuthService', ()=>{
|
|
46
|
+
let configRepo;
|
|
47
|
+
let userRepo;
|
|
48
|
+
let cache;
|
|
49
|
+
let authService;
|
|
50
|
+
let fetchProfile;
|
|
51
|
+
let buildAuthorizationUrl;
|
|
52
|
+
let signUpEnabled;
|
|
53
|
+
let companyFeatureEnabled;
|
|
54
|
+
function buildService() {
|
|
55
|
+
const dataSourceProvider = {
|
|
56
|
+
getRepository: jest.fn().mockImplementation(async (entity)=>{
|
|
57
|
+
if (entity === SocialAuthConfig) return configRepo;
|
|
58
|
+
if (entity === AppUser) return userRepo;
|
|
59
|
+
throw new Error(`Unexpected entity: ${String(entity)}`);
|
|
60
|
+
}),
|
|
61
|
+
isSignUpEnabled: ()=>signUpEnabled,
|
|
62
|
+
isCompanyFeatureEnabled: ()=>companyFeatureEnabled
|
|
63
|
+
};
|
|
64
|
+
return new SocialAuthService(authService, dataSourceProvider, cache);
|
|
65
|
+
}
|
|
66
|
+
beforeEach(()=>{
|
|
67
|
+
signUpEnabled = true;
|
|
68
|
+
companyFeatureEnabled = false;
|
|
69
|
+
configRepo = createMockRepository();
|
|
70
|
+
userRepo = createMockRepository();
|
|
71
|
+
cache = {
|
|
72
|
+
get: jest.fn(),
|
|
73
|
+
set: jest.fn(),
|
|
74
|
+
del: jest.fn()
|
|
75
|
+
};
|
|
76
|
+
authService = {
|
|
77
|
+
issueLoginForUser: jest.fn().mockResolvedValue({
|
|
78
|
+
accessToken: 'access-token'
|
|
79
|
+
})
|
|
80
|
+
};
|
|
81
|
+
fetchProfile = jest.fn().mockResolvedValue(buildProfile());
|
|
82
|
+
buildAuthorizationUrl = jest.fn().mockReturnValue('https://provider.test/consent');
|
|
83
|
+
jest.spyOn(registry, 'getSocialProvider').mockReturnValue({
|
|
84
|
+
provider: SocialProviderTypeEnum.GOOGLE,
|
|
85
|
+
defaultScopes: [
|
|
86
|
+
'openid'
|
|
87
|
+
],
|
|
88
|
+
buildAuthorizationUrl,
|
|
89
|
+
fetchProfile
|
|
90
|
+
});
|
|
91
|
+
userRepo.create.mockImplementation((data)=>({
|
|
92
|
+
...data
|
|
93
|
+
}));
|
|
94
|
+
userRepo.save.mockImplementation(async (entity)=>({
|
|
95
|
+
id: 'new-user',
|
|
96
|
+
...entity
|
|
97
|
+
}));
|
|
98
|
+
});
|
|
99
|
+
afterEach(()=>jest.restoreAllMocks());
|
|
100
|
+
describe('listEnabledProviders', ()=>{
|
|
101
|
+
it('drops enabled providers that have no credentials yet', async ()=>{
|
|
102
|
+
configRepo.find.mockResolvedValue([
|
|
103
|
+
buildConfig(),
|
|
104
|
+
buildConfig({
|
|
105
|
+
id: 'config-2',
|
|
106
|
+
provider: SocialProviderTypeEnum.FACEBOOK,
|
|
107
|
+
clientSecret: null
|
|
108
|
+
})
|
|
109
|
+
]);
|
|
110
|
+
const providers = await buildService().listEnabledProviders(REDIRECT_URI);
|
|
111
|
+
expect(providers).toEqual([
|
|
112
|
+
{
|
|
113
|
+
provider: SocialProviderTypeEnum.GOOGLE,
|
|
114
|
+
redirectUri: REDIRECT_URI
|
|
115
|
+
}
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('createAuthorization', ()=>{
|
|
120
|
+
it('caches the state and returns the provider consent URL', async ()=>{
|
|
121
|
+
configRepo.findOne.mockResolvedValue(buildConfig());
|
|
122
|
+
const result = await buildService().createAuthorization(SocialProviderTypeEnum.GOOGLE, REDIRECT_URI);
|
|
123
|
+
expect(result.authorizationUrl).toBe('https://provider.test/consent');
|
|
124
|
+
expect(cache.set).toHaveBeenCalledWith(`auth:social:state:${result.state}`, {
|
|
125
|
+
provider: SocialProviderTypeEnum.GOOGLE,
|
|
126
|
+
redirectUri: REDIRECT_URI
|
|
127
|
+
}, 10 * 60 * 1000);
|
|
128
|
+
});
|
|
129
|
+
it('rejects a provider that is configured but switched off', async ()=>{
|
|
130
|
+
configRepo.findOne.mockResolvedValue(buildConfig({
|
|
131
|
+
isEnabled: false
|
|
132
|
+
}));
|
|
133
|
+
await expect(buildService().createAuthorization(SocialProviderTypeEnum.GOOGLE, REDIRECT_URI)).rejects.toThrow(BadRequestException);
|
|
134
|
+
});
|
|
135
|
+
it('rejects a provider that is switched on but has no credentials', async ()=>{
|
|
136
|
+
configRepo.findOne.mockResolvedValue(buildConfig({
|
|
137
|
+
clientId: null
|
|
138
|
+
}));
|
|
139
|
+
await expect(buildService().createAuthorization(SocialProviderTypeEnum.GOOGLE, REDIRECT_URI)).rejects.toThrow(BadRequestException);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
describe('completeLogin', ()=>{
|
|
143
|
+
beforeEach(()=>{
|
|
144
|
+
cache.get.mockResolvedValue({
|
|
145
|
+
provider: SocialProviderTypeEnum.GOOGLE,
|
|
146
|
+
redirectUri: REDIRECT_URI
|
|
147
|
+
});
|
|
148
|
+
configRepo.findOne.mockResolvedValue(buildConfig());
|
|
149
|
+
});
|
|
150
|
+
it('signs in the AppUser that already has this email, marking the email verified', async ()=>{
|
|
151
|
+
userRepo.findOne.mockResolvedValue(buildUser());
|
|
152
|
+
const result = await buildService().completeLogin('code', 'state', REDIRECT_URI);
|
|
153
|
+
// The provider already proved ownership of the email, so a previously-unverified
|
|
154
|
+
// account is marked verified rather than silently bypassing app-side verification.
|
|
155
|
+
expect(userRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
|
156
|
+
id: 'user-1',
|
|
157
|
+
emailVerified: true
|
|
158
|
+
}));
|
|
159
|
+
expect(authService.issueLoginForUser).toHaveBeenCalledWith(expect.objectContaining({
|
|
160
|
+
id: 'user-1',
|
|
161
|
+
emailVerified: true
|
|
162
|
+
}));
|
|
163
|
+
expect(result).toEqual({
|
|
164
|
+
accessToken: 'access-token'
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
it('refuses to sign in or re-register an email that belongs to a permanently deleted account', async ()=>{
|
|
168
|
+
userRepo.findOne.mockResolvedValue(buildUser({
|
|
169
|
+
deletedAt: new Date()
|
|
170
|
+
}));
|
|
171
|
+
await expect(buildService().completeLogin('code', 'state', REDIRECT_URI)).rejects.toThrow(UnauthorizedException);
|
|
172
|
+
expect(userRepo.findOne).toHaveBeenCalledWith(expect.objectContaining({
|
|
173
|
+
withDeleted: true
|
|
174
|
+
}));
|
|
175
|
+
expect(userRepo.save).not.toHaveBeenCalled();
|
|
176
|
+
expect(userRepo.create).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
it('does not re-save an existing user whose email is already verified', async ()=>{
|
|
179
|
+
userRepo.findOne.mockResolvedValue(buildUser({
|
|
180
|
+
emailVerified: true
|
|
181
|
+
}));
|
|
182
|
+
await buildService().completeLogin('code', 'state', REDIRECT_URI);
|
|
183
|
+
expect(userRepo.save).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
it('adopts an existing password account that shares the email without creating a new one', async ()=>{
|
|
186
|
+
userRepo.findOne.mockResolvedValue(buildUser());
|
|
187
|
+
await buildService().completeLogin('code', 'state', REDIRECT_URI);
|
|
188
|
+
expect(userRepo.create).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
it('creates a new AppUser with no password on a first sign-in', async ()=>{
|
|
191
|
+
userRepo.findOne.mockResolvedValue(null);
|
|
192
|
+
await buildService().completeLogin('code', 'state', REDIRECT_URI);
|
|
193
|
+
expect(userRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
|
194
|
+
email: 'user@example.com',
|
|
195
|
+
password: null,
|
|
196
|
+
emailVerified: true
|
|
197
|
+
}));
|
|
198
|
+
});
|
|
199
|
+
it('refuses an unknown email when sign-up is disabled', async ()=>{
|
|
200
|
+
signUpEnabled = false;
|
|
201
|
+
userRepo.findOne.mockResolvedValue(null);
|
|
202
|
+
await expect(buildService().completeLogin('code', 'state', REDIRECT_URI)).rejects.toThrow(UnauthorizedException);
|
|
203
|
+
expect(userRepo.save).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
it('signs an existing user in even when sign-up is disabled', async ()=>{
|
|
206
|
+
signUpEnabled = false;
|
|
207
|
+
userRepo.findOne.mockResolvedValue(buildUser());
|
|
208
|
+
await expect(buildService().completeLogin('code', 'state', REDIRECT_URI)).resolves.toEqual({
|
|
209
|
+
accessToken: 'access-token'
|
|
210
|
+
});
|
|
211
|
+
expect(userRepo.create).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
it('refuses to create an account that could not be given a company', async ()=>{
|
|
214
|
+
companyFeatureEnabled = true;
|
|
215
|
+
userRepo.findOne.mockResolvedValue(null);
|
|
216
|
+
await expect(buildService().completeLogin('code', 'state', REDIRECT_URI)).rejects.toThrow(UnauthorizedException);
|
|
217
|
+
expect(userRepo.save).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
it('signs in a user who already has a company, company feature and all', async ()=>{
|
|
220
|
+
companyFeatureEnabled = true;
|
|
221
|
+
userRepo.findOne.mockResolvedValue(buildUser());
|
|
222
|
+
await expect(buildService().completeLogin('code', 'state', REDIRECT_URI)).resolves.toEqual({
|
|
223
|
+
accessToken: 'access-token'
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
it('refuses an email the provider has not verified', async ()=>{
|
|
227
|
+
fetchProfile.mockResolvedValue(buildProfile({
|
|
228
|
+
emailVerified: false
|
|
229
|
+
}));
|
|
230
|
+
userRepo.findOne.mockResolvedValue(buildUser());
|
|
231
|
+
await expect(buildService().completeLogin('code', 'state', REDIRECT_URI)).rejects.toThrow(UnauthorizedException);
|
|
232
|
+
expect(userRepo.findOne).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
it('marks an auto-created account verified, since only verified profiles get that far', async ()=>{
|
|
235
|
+
userRepo.findOne.mockResolvedValue(null);
|
|
236
|
+
await buildService().completeLogin('code', 'state', REDIRECT_URI);
|
|
237
|
+
expect(userRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
|
238
|
+
emailVerified: true
|
|
239
|
+
}));
|
|
240
|
+
});
|
|
241
|
+
it('refuses a profile the provider gave no email for', async ()=>{
|
|
242
|
+
fetchProfile.mockResolvedValue(buildProfile({
|
|
243
|
+
email: null
|
|
244
|
+
}));
|
|
245
|
+
await expect(buildService().completeLogin('code', 'state', REDIRECT_URI)).rejects.toThrow(UnauthorizedException);
|
|
246
|
+
});
|
|
247
|
+
it('refuses a profile with no account identifier', async ()=>{
|
|
248
|
+
fetchProfile.mockResolvedValue(buildProfile({
|
|
249
|
+
providerUserId: ''
|
|
250
|
+
}));
|
|
251
|
+
await expect(buildService().completeLogin('code', 'state', REDIRECT_URI)).rejects.toThrow(UnauthorizedException);
|
|
252
|
+
});
|
|
253
|
+
it('refuses an unknown or expired state', async ()=>{
|
|
254
|
+
cache.get.mockResolvedValue(undefined);
|
|
255
|
+
await expect(buildService().completeLogin('code', 'state', REDIRECT_URI)).rejects.toThrow(UnauthorizedException);
|
|
256
|
+
});
|
|
257
|
+
it('refuses a redirect URI the sign-in did not start with', async ()=>{
|
|
258
|
+
await expect(buildService().completeLogin('code', 'state', 'https://evil.test/callback')).rejects.toThrow(UnauthorizedException);
|
|
259
|
+
});
|
|
260
|
+
it('burns the state even when the redirect URI does not match, so it cannot be retried', async ()=>{
|
|
261
|
+
await expect(buildService().completeLogin('code', 'state', 'https://evil.test/callback')).rejects.toThrow(UnauthorizedException);
|
|
262
|
+
expect(cache.del).toHaveBeenCalledWith('auth:social:state:state');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
package/index.d.ts
CHANGED
package/interfaces/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from './authentication.interface';
|
|
|
2
2
|
export * from './auth-email-provider.interface';
|
|
3
3
|
export * from './company-branch.interface';
|
|
4
4
|
export * from './company.interface';
|
|
5
|
+
export * from './social-auth.interface';
|
|
5
6
|
export * from './user.interface';
|
|
6
7
|
export * from './user-enricher.interface';
|
|
7
8
|
export * from './auth-module-options.interface';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { IIdentity } from '@flusys/nestjs-shared/interfaces';
|
|
2
|
+
import { SocialProviderTypeEnum } from '../enums/social-provider-type.enum';
|
|
3
|
+
export interface ISocialAuthConfig extends IIdentity {
|
|
4
|
+
provider: SocialProviderTypeEnum;
|
|
5
|
+
isEnabled: boolean;
|
|
6
|
+
clientId: string | null;
|
|
7
|
+
hasClientSecret: boolean;
|
|
8
|
+
scopes: string[] | null;
|
|
9
|
+
extraConfig: Record<string, unknown> | null;
|
|
10
|
+
}
|
|
11
|
+
export interface ISocialProviderInfo {
|
|
12
|
+
provider: SocialProviderTypeEnum;
|
|
13
|
+
redirectUri: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ISocialAuthorization {
|
|
16
|
+
provider: SocialProviderTypeEnum;
|
|
17
|
+
authorizationUrl: string;
|
|
18
|
+
state: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ISocialProfile {
|
|
21
|
+
providerUserId: string;
|
|
22
|
+
email: string | null;
|
|
23
|
+
emailVerified: boolean;
|
|
24
|
+
name: string | null;
|
|
25
|
+
avatarUrl: string | null;
|
|
26
|
+
}
|
|
27
|
+
export interface ISocialProviderCredentials {
|
|
28
|
+
clientId: string;
|
|
29
|
+
clientSecret: string;
|
|
30
|
+
redirectUri: string;
|
|
31
|
+
scopes?: string[] | null;
|
|
32
|
+
extraConfig?: Record<string, unknown> | null;
|
|
33
|
+
}
|
|
34
|
+
export interface ISocialAuthProvider {
|
|
35
|
+
readonly provider: SocialProviderTypeEnum;
|
|
36
|
+
readonly defaultScopes: string[];
|
|
37
|
+
buildAuthorizationUrl(credentials: ISocialProviderCredentials, state: string): string;
|
|
38
|
+
fetchProfile(credentials: ISocialProviderCredentials, code: string): Promise<ISocialProfile>;
|
|
39
|
+
}
|
|
40
|
+
export interface ISocialAuthState {
|
|
41
|
+
provider: SocialProviderTypeEnum;
|
|
42
|
+
redirectUri: string;
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flusys/nestjs-auth",
|
|
3
|
-
"version": "7.0
|
|
3
|
+
"version": "7.1.0",
|
|
4
4
|
"description": "Authentication and authorization infrastructure for NestJS applications",
|
|
5
5
|
"main": "cjs/index.js",
|
|
6
6
|
"module": "fesm/index.js",
|
|
@@ -98,7 +98,7 @@
|
|
|
98
98
|
"rxjs": "^7.8.0"
|
|
99
99
|
},
|
|
100
100
|
"dependencies": {
|
|
101
|
-
"@flusys/nestjs-core": "7.0
|
|
102
|
-
"@flusys/nestjs-shared": "7.0
|
|
101
|
+
"@flusys/nestjs-core": "7.1.0",
|
|
102
|
+
"@flusys/nestjs-shared": "7.1.0"
|
|
103
103
|
}
|
|
104
104
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { SocialProviderTypeEnum } from '../enums/social-provider-type.enum';
|
|
2
|
+
import { ISocialAuthProvider, ISocialProfile, ISocialProviderCredentials } from '../interfaces/social-auth.interface';
|
|
3
|
+
declare class FacebookSocialProvider implements ISocialAuthProvider {
|
|
4
|
+
readonly provider = SocialProviderTypeEnum.FACEBOOK;
|
|
5
|
+
readonly defaultScopes: string[];
|
|
6
|
+
buildAuthorizationUrl(credentials: ISocialProviderCredentials, state: string): string;
|
|
7
|
+
fetchProfile(credentials: ISocialProviderCredentials, code: string): Promise<ISocialProfile>;
|
|
8
|
+
}
|
|
9
|
+
export declare const facebookSocialProvider: FacebookSocialProvider;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { SocialProviderTypeEnum } from '../enums/social-provider-type.enum';
|
|
2
|
+
import { ISocialAuthProvider, ISocialProfile, ISocialProviderCredentials } from '../interfaces/social-auth.interface';
|
|
3
|
+
declare class GoogleSocialProvider implements ISocialAuthProvider {
|
|
4
|
+
readonly provider = SocialProviderTypeEnum.GOOGLE;
|
|
5
|
+
readonly defaultScopes: string[];
|
|
6
|
+
buildAuthorizationUrl(credentials: ISocialProviderCredentials, state: string): string;
|
|
7
|
+
fetchProfile(credentials: ISocialProviderCredentials, code: string): Promise<ISocialProfile>;
|
|
8
|
+
}
|
|
9
|
+
export declare const googleSocialProvider: GoogleSocialProvider;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './social-provider.registry';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { SocialProviderTypeEnum } from '../enums/social-provider-type.enum';
|
|
2
|
+
import { ISocialAuthProvider, ISocialProfile, ISocialProviderCredentials } from '../interfaces/social-auth.interface';
|
|
3
|
+
declare class LinkedInSocialProvider implements ISocialAuthProvider {
|
|
4
|
+
readonly provider = SocialProviderTypeEnum.LINKEDIN;
|
|
5
|
+
readonly defaultScopes: string[];
|
|
6
|
+
buildAuthorizationUrl(credentials: ISocialProviderCredentials, state: string): string;
|
|
7
|
+
fetchProfile(credentials: ISocialProviderCredentials, code: string): Promise<ISocialProfile>;
|
|
8
|
+
}
|
|
9
|
+
export declare const linkedInSocialProvider: LinkedInSocialProvider;
|
|
10
|
+
export {};
|