@stamhoofd/backend 2.75.1 → 2.76.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stamhoofd/backend",
3
- "version": "2.75.1",
3
+ "version": "2.76.0",
4
4
  "main": "./dist/index.js",
5
5
  "exports": {
6
6
  ".": {
@@ -37,14 +37,14 @@
37
37
  "@simonbackx/simple-encoding": "2.20.0",
38
38
  "@simonbackx/simple-endpoints": "1.19.0",
39
39
  "@simonbackx/simple-logging": "^1.0.1",
40
- "@stamhoofd/backend-i18n": "2.75.1",
41
- "@stamhoofd/backend-middleware": "2.75.1",
42
- "@stamhoofd/email": "2.75.1",
43
- "@stamhoofd/models": "2.75.1",
44
- "@stamhoofd/queues": "2.75.1",
45
- "@stamhoofd/sql": "2.75.1",
46
- "@stamhoofd/structures": "2.75.1",
47
- "@stamhoofd/utility": "2.75.1",
40
+ "@stamhoofd/backend-i18n": "2.76.0",
41
+ "@stamhoofd/backend-middleware": "2.76.0",
42
+ "@stamhoofd/email": "2.76.0",
43
+ "@stamhoofd/models": "2.76.0",
44
+ "@stamhoofd/queues": "2.76.0",
45
+ "@stamhoofd/sql": "2.76.0",
46
+ "@stamhoofd/structures": "2.76.0",
47
+ "@stamhoofd/utility": "2.76.0",
48
48
  "archiver": "^7.0.1",
49
49
  "aws-sdk": "^2.885.0",
50
50
  "axios": "1.6.8",
@@ -64,5 +64,5 @@
64
64
  "publishConfig": {
65
65
  "access": "public"
66
66
  },
67
- "gitHead": "4c6a7f14c62e4832885325d02c5aaeeccf27568b"
67
+ "gitHead": "85e0625ba777d83d92ad582443df1666955d51f6"
68
68
  }
@@ -0,0 +1,43 @@
1
+ import { AutoEncoder, field, StringDecoder } from '@simonbackx/simple-encoding';
2
+ import { DecodedRequest, Endpoint, Request, Response } from '@simonbackx/simple-endpoints';
3
+
4
+ import { Context } from '../../helpers/Context';
5
+ import { SSOService } from '../../services/SSOService';
6
+ import { OpenIDAuthTokenResponse } from '@stamhoofd/structures';
7
+
8
+ type Params = Record<string, never>;
9
+ type Query = undefined;
10
+ type Body = undefined;
11
+ type ResponseBody = OpenIDAuthTokenResponse;
12
+
13
+ /**
14
+ * This endpoint does nothing but build a URL to start the OpenID Connect flow.
15
+ * It is used to provide authenticateion data to the url that is temporarily valid (allows to connect an SSO provider to an existing account)
16
+ */
17
+ export class OpenIDConnectAuthTokenEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
18
+ protected doesMatch(request: Request): [true, Params] | [false] {
19
+ if (request.method !== 'POST') {
20
+ return [false];
21
+ }
22
+
23
+ const params = Endpoint.parseParameters(request.url, '/openid/auth-token', {});
24
+
25
+ if (params) {
26
+ return [true, params as Params];
27
+ }
28
+ return [false];
29
+ }
30
+
31
+ async handle(request: DecodedRequest<Params, Query, Body>) {
32
+ // Check webshop and/or organization
33
+ await Context.setUserOrganizationScope();
34
+ await Context.authenticate({ allowWithoutAccount: false });
35
+
36
+ // Create a SSO auth token that can only be used once
37
+ const token = await SSOService.createToken();
38
+
39
+ return new Response(OpenIDAuthTokenResponse.create({
40
+ ssoAuthToken: token,
41
+ }));
42
+ }
43
+ }
@@ -6,15 +6,15 @@ import { Context } from '../../helpers/Context';
6
6
  import { SSOService } from '../../services/SSOService';
7
7
 
8
8
  type Params = Record<string, never>;
9
- type Query = undefined;
10
- type Body = StartOpenIDFlowStruct;
9
+ type Query = StartOpenIDFlowStruct;
10
+ type Body = undefined;
11
11
  type ResponseBody = undefined;
12
12
 
13
13
  export class OpenIDConnectStartEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
14
- bodyDecoder = StartOpenIDFlowStruct as Decoder<StartOpenIDFlowStruct>;
14
+ queryDecoder = StartOpenIDFlowStruct as Decoder<StartOpenIDFlowStruct>;
15
15
 
16
16
  protected doesMatch(request: Request): [true, Params] | [false] {
17
- if (request.method !== 'POST') {
17
+ if (request.method !== 'GET') {
18
18
  return [false];
19
19
  }
20
20
 
@@ -29,8 +29,7 @@ export class OpenIDConnectStartEndpoint extends Endpoint<Params, Query, Body, Re
29
29
  async handle(request: DecodedRequest<Params, Query, Body>) {
30
30
  // Check webshop and/or organization
31
31
  await Context.setUserOrganizationScope();
32
- await Context.optionalAuthenticate({ allowWithoutAccount: false });
33
- const service = await SSOService.fromContext(request.body.provider);
34
- return await service.validateAndStartAuthCodeFlow(request.body);
32
+ const service = await SSOService.fromContext(request.query.provider);
33
+ return await service.validateAndStartAuthCodeFlow(request.query);
35
34
  }
36
35
  }
@@ -186,7 +186,7 @@ export class UploadFile extends Endpoint<Params, Query, Body, ResponseBody> {
186
186
  throw new SimpleError({
187
187
  code: 'failed_to_sign',
188
188
  message: 'Failed to sign file',
189
- human: $t('Er ging iet mis bij het uploaden van jouw bestand. Probeer het later opnieuw (foutcode: SIGN).'),
189
+ human: $t('509cdb4f-131a-42a6-b3b1-a63cca231e65'),
190
190
  statusCode: 500,
191
191
  });
192
192
  }
@@ -5,6 +5,7 @@ import crypto from 'crypto';
5
5
  import basex from 'base-x';
6
6
  import { AuditLogService } from '../services/AuditLogService';
7
7
  import { Formatter } from '@stamhoofd/utility';
8
+ import { QueueHandler } from '@stamhoofd/queues';
8
9
 
9
10
  const ALPHABET = '123456789ABCDEFGHJKMNPQRSTUVWXYZ'; // Note: we removed 0, O, I and l to make it easier for humans
10
11
  const customBase = basex(ALPHABET);
@@ -203,116 +204,119 @@ export class MemberUserSyncerStatic {
203
204
  }
204
205
 
205
206
  async linkUser(email: string, member: MemberWithRegistrations, asParent: boolean, updateNameIfEqual = true) {
206
- console.log('Linking user', email, 'to member', member.id, 'as parent', asParent, 'update name if equal', updateNameIfEqual);
207
-
208
- let user = member.users.find(u => u.email.toLocaleLowerCase() === email.toLocaleLowerCase()) ?? await User.getForAuthentication(member.organizationId, email, { allowWithoutAccount: true });
209
-
210
- if (user) {
211
- // console.log("Giving an existing user access to a member: " + user.id + ' - ' + member.id)
212
- if (!asParent) {
213
- if (user.memberId && user.memberId !== member.id) {
214
- console.error('Found conflicting user with multiple members', user.id, 'members', user.memberId, 'to', member.id);
215
-
216
- const otherMember = await Member.getWithRegistrations(user.memberId);
217
-
218
- if (otherMember) {
219
- if (otherMember.registrations.length > 0 && member.registrations.length === 0) {
220
- // Choose the other member
221
- // don't make changes
222
- console.error('Resolved to current member - no changes made');
223
- return;
207
+ // This needs to happen in the queue to prevent creating duplicate users in the database
208
+ await QueueHandler.schedule('link-user/' + email, async () => {
209
+ console.log('Linking user', email, 'to member', member.id, 'as parent', asParent, 'update name if equal', updateNameIfEqual);
210
+
211
+ let user = member.users.find(u => u.email.toLocaleLowerCase() === email.toLocaleLowerCase()) ?? await User.getForAuthentication(member.organizationId, email, { allowWithoutAccount: true });
212
+
213
+ if (user) {
214
+ // console.log("Giving an existing user access to a member: " + user.id + ' - ' + member.id)
215
+ if (!asParent) {
216
+ if (user.memberId && user.memberId !== member.id) {
217
+ console.error('Found conflicting user with multiple members', user.id, 'members', user.memberId, 'to', member.id);
218
+
219
+ const otherMember = await Member.getWithRegistrations(user.memberId);
220
+
221
+ if (otherMember) {
222
+ if (otherMember.registrations.length > 0 && member.registrations.length === 0) {
223
+ // Choose the other member
224
+ // don't make changes
225
+ console.error('Resolved to current member - no changes made');
226
+ return;
227
+ }
228
+
229
+ const responsibilities = await this.getResponsibilitiesForMembers([otherMember.id, member.id]);
230
+ const responsibilitiesOther = responsibilities.filter(r => r.memberId === otherMember.id);
231
+ const responsibilitiesCurrent = responsibilities.filter(r => r.memberId === member.id);
232
+
233
+ if (responsibilitiesOther.length >= responsibilitiesCurrent.length) {
234
+ console.error('Resolved to current member because of more responsibilities - no changes made');
235
+ return;
236
+ }
224
237
  }
238
+ }
225
239
 
226
- const responsibilities = await this.getResponsibilitiesForMembers([otherMember.id, member.id]);
227
- const responsibilitiesOther = responsibilities.filter(r => r.memberId === otherMember.id);
228
- const responsibilitiesCurrent = responsibilities.filter(r => r.memberId === member.id);
229
-
230
- if (responsibilitiesOther.length >= responsibilitiesCurrent.length) {
231
- console.error('Resolved to current member because of more responsibilities - no changes made');
232
- return;
233
- }
240
+ if (updateNameIfEqual) {
241
+ user.firstName = member.details.firstName;
242
+ user.lastName = member.details.lastName;
234
243
  }
244
+ user.memberId = member.id;
245
+ await this.updateInheritedPermissions(user);
235
246
  }
247
+ else {
248
+ let shouldSave = false;
249
+
250
+ if (!user.firstName && !user.lastName) {
251
+ const parents = member.details.parents.filter(p => p.email === email);
252
+ if (parents.length === 1) {
253
+ if (updateNameIfEqual) {
254
+ user.firstName = parents[0].firstName;
255
+ user.lastName = parents[0].lastName;
256
+ }
257
+ shouldSave = true;
258
+ }
259
+ }
236
260
 
237
- if (updateNameIfEqual) {
238
- user.firstName = member.details.firstName;
239
- user.lastName = member.details.lastName;
261
+ if (user.firstName === member.details.firstName && user.lastName === member.details.lastName) {
262
+ user.firstName = null;
263
+ user.lastName = null;
264
+ shouldSave = true;
265
+ }
266
+
267
+ if (user.memberId === member.id) {
268
+ // Unlink: parents are never 'equal' to the member
269
+ user.memberId = null;
270
+ await this.updateInheritedPermissions(user);
271
+ }
272
+ if (shouldSave) {
273
+ await user.save();
274
+ }
240
275
  }
241
- user.memberId = member.id;
242
- await this.updateInheritedPermissions(user);
243
276
  }
244
277
  else {
245
- let shouldSave = false;
278
+ // Create a new placeholder user
279
+ user = new User();
280
+ user.organizationId = member.organizationId;
281
+ user.email = email;
246
282
 
247
- if (!user.firstName && !user.lastName) {
283
+ if (!asParent) {
284
+ if (updateNameIfEqual) {
285
+ user.firstName = member.details.firstName;
286
+ user.lastName = member.details.lastName;
287
+ }
288
+ user.memberId = member.id;
289
+ await this.updateInheritedPermissions(user);
290
+ }
291
+ else {
248
292
  const parents = member.details.parents.filter(p => p.email === email);
249
293
  if (parents.length === 1) {
250
294
  if (updateNameIfEqual) {
251
295
  user.firstName = parents[0].firstName;
252
296
  user.lastName = parents[0].lastName;
253
297
  }
254
- shouldSave = true;
255
298
  }
256
- }
257
-
258
- if (user.firstName === member.details.firstName && user.lastName === member.details.lastName) {
259
- user.firstName = null;
260
- user.lastName = null;
261
- shouldSave = true;
262
- }
263
299
 
264
- if (user.memberId === member.id) {
265
- // Unlink: parents are never 'equal' to the member
266
- user.memberId = null;
267
- await this.updateInheritedPermissions(user);
268
- }
269
- if (shouldSave) {
270
- await user.save();
271
- }
272
- }
273
- }
274
- else {
275
- // Create a new placeholder user
276
- user = new User();
277
- user.organizationId = member.organizationId;
278
- user.email = email;
279
-
280
- if (!asParent) {
281
- if (updateNameIfEqual) {
282
- user.firstName = member.details.firstName;
283
- user.lastName = member.details.lastName;
284
- }
285
- user.memberId = member.id;
286
- await this.updateInheritedPermissions(user);
287
- }
288
- else {
289
- const parents = member.details.parents.filter(p => p.email === email);
290
- if (parents.length === 1) {
291
- if (updateNameIfEqual) {
292
- user.firstName = parents[0].firstName;
293
- user.lastName = parents[0].lastName;
300
+ if (user.firstName === member.details.firstName && user.lastName === member.details.lastName) {
301
+ user.firstName = null;
302
+ user.lastName = null;
294
303
  }
295
- }
296
304
 
297
- if (user.firstName === member.details.firstName && user.lastName === member.details.lastName) {
298
- user.firstName = null;
299
- user.lastName = null;
305
+ await user.save();
300
306
  }
301
307
 
302
- await user.save();
308
+ console.log('Created new (placeholder) user that has access to a member: ' + user.id);
303
309
  }
304
310
 
305
- console.log('Created new (placeholder) user that has access to a member: ' + user.id);
306
- }
307
-
308
- // Update model relation to correct response
309
- if (!member.users.find(u => u.id === user.id)) {
310
- await Member.users.reverse('members').link(user, [member]);
311
- member.users.push(user);
311
+ // Update model relation to correct response
312
+ if (!member.users.find(u => u.id === user.id)) {
313
+ await Member.users.reverse('members').link(user, [member]);
314
+ member.users.push(user);
312
315
 
313
- // Update balance of this user, as it could have changed
314
- await this.updateUserBalance(user.id, member.id);
315
- }
316
+ // Update balance of this user, as it could have changed
317
+ await this.updateUserBalance(user.id, member.id);
318
+ }
319
+ });
316
320
  }
317
321
 
318
322
  /**
@@ -175,7 +175,7 @@ export class FileSignService {
175
175
  throw new SimpleError({
176
176
  code: 'invalid_signature',
177
177
  message: 'Invalid signature for file',
178
- human: $t('Je probeert een bestand up te loaden dat niet door ons is gegenereerd. Probeer het opnieuw.'),
178
+ human: $t('479684ab-6c50-4ced-82d7-8245f4f475f4'),
179
179
  });
180
180
  }
181
181
  return;
@@ -35,19 +35,71 @@ type SSOSessionContext = {
35
35
  userId?: string | null;
36
36
  };
37
37
 
38
+ export class SSOAuthToken {
39
+ validUntil: Date;
40
+ token: string;
41
+ userId: string;
42
+ }
43
+
38
44
  export class SSOService {
39
45
  provider: LoginProviderType;
40
46
  platform: Platform;
41
47
  organization: Organization | null;
42
- user: User | null = null;
43
48
 
44
49
  static sessionStorage = new Map<string, SSOSessionContext>();
45
50
 
46
- constructor(data: { provider: LoginProviderType; platform: Platform; organization?: Organization | null; user?: User | null }) {
51
+ /**
52
+ * Maps auth token to user id + expiry information
53
+ */
54
+ static authTokens = new Map<string, SSOAuthToken>();
55
+
56
+ constructor(data: { provider: LoginProviderType; platform: Platform; organization?: Organization | null }) {
47
57
  this.provider = data.provider;
48
58
  this.platform = data.platform;
49
59
  this.organization = data.organization ?? null;
50
- this.user = data.user ?? null;
60
+ }
61
+
62
+ static async clearExpiredTokensOrFromUser(userId: string | null = null) {
63
+ const d = new Date();
64
+ for (const [key, value] of this.authTokens) {
65
+ if (value.userId === userId || value.validUntil < d) {
66
+ this.authTokens.delete(key);
67
+ }
68
+ }
69
+ }
70
+
71
+ static async createToken() {
72
+ if (!Context.user) {
73
+ throw new SimpleError({
74
+ code: 'invalid_user',
75
+ message: 'Not signed in',
76
+ statusCode: 401,
77
+ });
78
+ }
79
+
80
+ const token = new SSOAuthToken();
81
+ token.validUntil = new Date(Date.now() + 1000 * 60 * 5);
82
+ token.token = (await randomBytes(192)).toString('base64').toUpperCase();
83
+ token.userId = Context.user.id;
84
+
85
+ await this.clearExpiredTokensOrFromUser(token.userId);
86
+ this.authTokens.set(token.token, token);
87
+
88
+ return token.token;
89
+ }
90
+
91
+ static async validateToken(token: string) {
92
+ const authToken = this.authTokens.get(token);
93
+ if (!authToken || authToken.validUntil < new Date()) {
94
+ this.authTokens.delete(token);
95
+ throw new SimpleError({
96
+ code: 'invalid_token',
97
+ message: 'Invalid token',
98
+ statusCode: 401,
99
+ });
100
+ }
101
+ this.authTokens.delete(token);
102
+ return authToken;
51
103
  }
52
104
 
53
105
  /**
@@ -80,7 +132,7 @@ export class SSOService {
80
132
  const organization = Context.organization;
81
133
  const platform = await Platform.getShared();
82
134
 
83
- const service = new SSOService({ provider, platform, organization, user: Context.user });
135
+ const service = new SSOService({ provider, platform, organization });
84
136
  service.validateConfiguration();
85
137
 
86
138
  return service;
@@ -144,12 +196,6 @@ export class SSOService {
144
196
  const __ = this.loginConfiguration;
145
197
  }
146
198
 
147
- validateUser() {
148
- if (this.user) {
149
- this.validateEmail(this.user.email);
150
- }
151
- }
152
-
153
199
  validateEmail(email: string) {
154
200
  // Validate configuration
155
201
  const loginConfiguration = this.loginConfiguration;
@@ -309,28 +355,77 @@ export class SSOService {
309
355
  });
310
356
  }
311
357
 
312
- return await this.startAuthCodeFlow(redirectUri, data.spaState, data.prompt);
358
+ let user: User | undefined = undefined;
359
+
360
+ if (data.authToken) {
361
+ const token = await SSOService.validateToken(data.authToken);
362
+ if (token) {
363
+ user = await User.getByID(token.userId);
364
+
365
+ if (!user) {
366
+ throw new SimpleError({
367
+ code: 'invalid_user',
368
+ message: 'User not found',
369
+ statusCode: 404,
370
+ });
371
+ }
372
+
373
+ this.validateEmail(user.email);
374
+ }
375
+ }
376
+
377
+ return await this.startAuthCodeFlow(redirectUri, data.spaState, data.prompt, user);
378
+ }
379
+
380
+ validateRedirectUri(uri: string) {
381
+ let parsed: URL;
382
+ try {
383
+ parsed = new URL(uri);
384
+ }
385
+ catch (e) {
386
+ throw new SimpleError({
387
+ code: 'invalid_redirect_uri',
388
+ message: 'Invalid redirect uri',
389
+ field: 'redirectUri',
390
+ statusCode: 400,
391
+ });
392
+ }
393
+
394
+ if (parsed.protocol !== 'https:') {
395
+ throw new SimpleError({
396
+ code: 'invalid_redirect_uri',
397
+ message: 'Invalid redirect uri',
398
+ field: 'redirectUri',
399
+ statusCode: 400,
400
+ });
401
+ }
402
+
403
+ if (parsed.host !== STAMHOOFD.domains.dashboard) {
404
+ throw new SimpleError({
405
+ code: 'invalid_redirect_uri',
406
+ message: 'Invalid redirect uri',
407
+ field: 'redirectUri',
408
+ statusCode: 400,
409
+ });
410
+ }
313
411
  }
314
412
 
315
- async startAuthCodeFlow(redirectUri: string, spaState: string, prompt: string | null = null): Promise<Response<undefined>> {
413
+ async startAuthCodeFlow(redirectUri: string, spaState: string, prompt: string | null = null, user?: User): Promise<Response<undefined>> {
316
414
  const code_verifier = generators.codeVerifier();
317
- const state = generators.state();
415
+ const state = generators.state(); // this is the internal state backend <-> SSO provider
318
416
  const nonce = generators.nonce();
319
417
  const code_challenge = generators.codeChallenge(code_verifier);
320
418
  const expires = new Date(Date.now() + 1000 * 60 * 15);
321
419
 
322
- // Validate user id
323
- this.validateUser();
324
-
325
420
  const session: SSOSessionContext = {
326
421
  expires,
327
422
  code_verifier,
328
423
  state,
329
424
  nonce,
330
425
  redirectUri,
331
- spaState,
426
+ spaState, // this is the state frontend <-> backend (not backend <-> SSO provider)
332
427
  providerType: this.provider,
333
- userId: Context.user?.id ?? null,
428
+ userId: user?.id ?? null,
334
429
  };
335
430
 
336
431
  try {
@@ -355,7 +450,7 @@ export class SSOService {
355
450
  state,
356
451
  nonce,
357
452
  prompt: prompt ?? undefined,
358
- login_hint: this.user?.email ?? undefined,
453
+ login_hint: user?.email ?? undefined,
359
454
  redirect_uri: this.externalRedirectUri,
360
455
 
361
456
  // Google has this instead of the offline_access scope