@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.
Files changed (108) hide show
  1. package/README.md +67 -0
  2. package/cjs/config/auth.constants.js +8 -0
  3. package/cjs/config/event-actions.js +4 -2
  4. package/cjs/config/message-keys.js +27 -0
  5. package/cjs/controllers/index.js +2 -0
  6. package/cjs/controllers/social-auth-config.controller.js +107 -0
  7. package/cjs/controllers/social-auth.controller.js +175 -0
  8. package/cjs/controllers/social-auth.controller.spec.js +79 -0
  9. package/cjs/docs/auth-swagger.config.js +9 -1
  10. package/cjs/dtos/index.js +1 -0
  11. package/cjs/dtos/social-auth.dto.js +318 -0
  12. package/cjs/entities/index.js +3 -1
  13. package/cjs/entities/index.spec.js +12 -1
  14. package/cjs/entities/social-auth-config.entity.js +102 -0
  15. package/cjs/enums/index.js +1 -0
  16. package/cjs/enums/social-provider-type.enum.js +17 -0
  17. package/cjs/index.js +1 -0
  18. package/cjs/interfaces/index.js +1 -0
  19. package/cjs/interfaces/social-auth.interface.js +4 -0
  20. package/cjs/modules/auth.module.js +6 -0
  21. package/cjs/modules/auth.module.spec.js +15 -0
  22. package/cjs/providers/facebook.social-provider.js +73 -0
  23. package/cjs/providers/google.social-provider.js +68 -0
  24. package/cjs/providers/index.js +18 -0
  25. package/cjs/providers/linkedin.social-provider.js +66 -0
  26. package/cjs/providers/microsoft.social-provider.js +78 -0
  27. package/cjs/providers/oauth-http.util.js +107 -0
  28. package/cjs/providers/social-provider.registry.js +24 -0
  29. package/cjs/providers/social-provider.registry.spec.js +135 -0
  30. package/cjs/services/auth-config.service.js +3 -0
  31. package/cjs/services/auth-config.service.spec.js +3 -0
  32. package/cjs/services/auth-datasource.provider.js +4 -0
  33. package/cjs/services/auth-datasource.provider.spec.js +30 -0
  34. package/cjs/services/authentication.service.js +38 -8
  35. package/cjs/services/authentication.service.spec.js +58 -0
  36. package/cjs/services/index.js +2 -0
  37. package/cjs/services/social-auth-config.service.js +103 -0
  38. package/cjs/services/social-auth-config.service.spec.js +71 -0
  39. package/cjs/services/social-auth.service.js +310 -0
  40. package/cjs/services/social-auth.service.spec.js +310 -0
  41. package/config/auth.constants.d.ts +2 -0
  42. package/config/event-actions.d.ts +2 -0
  43. package/config/message-keys.d.ts +21 -0
  44. package/controllers/index.d.ts +2 -0
  45. package/controllers/social-auth-config.controller.d.ts +22 -0
  46. package/controllers/social-auth.controller.d.ts +11 -0
  47. package/dtos/index.d.ts +1 -0
  48. package/dtos/social-auth.dto.d.ts +42 -0
  49. package/entities/index.d.ts +3 -1
  50. package/entities/social-auth-config.entity.d.ts +9 -0
  51. package/enums/index.d.ts +1 -0
  52. package/enums/social-provider-type.enum.d.ts +6 -0
  53. package/fesm/config/auth.constants.js +3 -0
  54. package/fesm/config/event-actions.js +4 -2
  55. package/fesm/config/message-keys.js +21 -0
  56. package/fesm/controllers/index.js +2 -0
  57. package/fesm/controllers/social-auth-config.controller.js +97 -0
  58. package/fesm/controllers/social-auth.controller.js +165 -0
  59. package/fesm/controllers/social-auth.controller.spec.js +75 -0
  60. package/fesm/docs/auth-swagger.config.js +9 -1
  61. package/fesm/dtos/index.js +1 -0
  62. package/fesm/dtos/social-auth.dto.js +282 -0
  63. package/fesm/entities/index.js +4 -1
  64. package/fesm/entities/index.spec.js +12 -1
  65. package/fesm/entities/social-auth-config.entity.js +92 -0
  66. package/fesm/enums/index.js +1 -0
  67. package/fesm/enums/social-provider-type.enum.js +7 -0
  68. package/fesm/index.js +1 -0
  69. package/fesm/interfaces/index.js +1 -0
  70. package/fesm/interfaces/social-auth.interface.js +1 -0
  71. package/fesm/modules/auth.module.js +8 -2
  72. package/fesm/modules/auth.module.spec.js +17 -2
  73. package/fesm/providers/facebook.social-provider.js +63 -0
  74. package/fesm/providers/google.social-provider.js +58 -0
  75. package/fesm/providers/index.js +1 -0
  76. package/fesm/providers/linkedin.social-provider.js +56 -0
  77. package/fesm/providers/microsoft.social-provider.js +68 -0
  78. package/fesm/providers/oauth-http.util.js +77 -0
  79. package/fesm/providers/social-provider.registry.js +14 -0
  80. package/fesm/providers/social-provider.registry.spec.js +131 -0
  81. package/fesm/services/auth-config.service.js +3 -0
  82. package/fesm/services/auth-config.service.spec.js +3 -0
  83. package/fesm/services/auth-datasource.provider.js +4 -0
  84. package/fesm/services/auth-datasource.provider.spec.js +30 -0
  85. package/fesm/services/authentication.service.js +38 -8
  86. package/fesm/services/authentication.service.spec.js +58 -0
  87. package/fesm/services/index.js +2 -0
  88. package/fesm/services/social-auth-config.service.js +93 -0
  89. package/fesm/services/social-auth-config.service.spec.js +67 -0
  90. package/fesm/services/social-auth.service.js +300 -0
  91. package/fesm/services/social-auth.service.spec.js +265 -0
  92. package/index.d.ts +1 -0
  93. package/interfaces/index.d.ts +1 -0
  94. package/interfaces/social-auth.interface.d.ts +43 -0
  95. package/package.json +3 -3
  96. package/providers/facebook.social-provider.d.ts +10 -0
  97. package/providers/google.social-provider.d.ts +10 -0
  98. package/providers/index.d.ts +1 -0
  99. package/providers/linkedin.social-provider.d.ts +10 -0
  100. package/providers/microsoft.social-provider.d.ts +10 -0
  101. package/providers/oauth-http.util.d.ts +10 -0
  102. package/providers/social-provider.registry.d.ts +3 -0
  103. package/services/auth-config.service.d.ts +1 -0
  104. package/services/auth-datasource.provider.d.ts +1 -0
  105. package/services/authentication.service.d.ts +4 -0
  106. package/services/index.d.ts +2 -0
  107. package/services/social-auth-config.service.d.ts +21 -0
  108. 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
@@ -8,5 +8,6 @@ export * from './helpers';
8
8
  export * from './interceptors';
9
9
  export * from './interfaces';
10
10
  export * from './modules';
11
+ export * from './providers';
11
12
  export * from './services';
12
13
  export * from './strategies/jwt.strategy';
@@ -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.2",
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.2",
102
- "@flusys/nestjs-shared": "7.0.2"
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 {};