@stamhoofd/backend 2.75.0 → 2.75.2

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.
@@ -87,24 +87,14 @@ export class FileSignService {
87
87
 
88
88
  file.signature = jws;
89
89
  };
90
- }
91
-
92
- static async fillSignedUrl(file: File, duration = 60 * 60) {
93
- if (!file.isPrivate) {
94
- if (file.signedUrl) {
95
- console.error('Warning: public file has a signed url');
96
- // this will not be encoded because of the file encode implementation
97
- }
98
- return;
99
- }
100
90
 
101
- // Only created signed urls for files that were generated by our own server
102
- if (!await file.verify()) {
103
- console.error('Failed to verify file:', file.id);
104
- return;
105
- }
91
+ File.getWithSignedUrl = async (file: File) => {
92
+ return this.withSignedUrl(file);
93
+ };
94
+ }
106
95
 
107
- console.log('Signing file:', file.id);
96
+ static async withSignedUrl(file: File, duration = 60 * 60) {
97
+ console.log('Generating signed url:', file.id);
108
98
 
109
99
  if (file.signedUrl) {
110
100
  console.error('Warning: file already signed');
@@ -126,13 +116,13 @@ export class FileSignService {
126
116
  }
127
117
  catch (e) {
128
118
  console.error('Failed to sign file:', e);
129
- return file;
119
+ return null;
130
120
  }
131
121
  }
132
122
 
133
123
  static async fillSignedUrlsForStruct(data: any) {
134
124
  if (data instanceof File) {
135
- return await this.fillSignedUrl(data);
125
+ return (await data.withSignedUrl()) ?? undefined; // never return null if it fails because we'll want to use the original file in that case
136
126
  }
137
127
 
138
128
  if (Array.isArray(data)) {
@@ -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,8 +132,8 @@ 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 });
84
- service.validate();
135
+ const service = new SSOService({ provider, platform, organization });
136
+ service.validateConfiguration();
85
137
 
86
138
  return service;
87
139
  }
@@ -138,14 +190,10 @@ export class SSOService {
138
190
  return loginConfiguration;
139
191
  }
140
192
 
141
- validate() {
193
+ validateConfiguration() {
142
194
  // Validate configuration exists
143
195
  const _ = this.configuration;
144
196
  const __ = this.loginConfiguration;
145
-
146
- if (this.user) {
147
- this.validateEmail(this.user.email);
148
- }
149
197
  }
150
198
 
151
199
  validateEmail(email: string) {
@@ -307,12 +355,64 @@ export class SSOService {
307
355
  });
308
356
  }
309
357
 
310
- 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
+ }
311
411
  }
312
412
 
313
- 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>> {
314
414
  const code_verifier = generators.codeVerifier();
315
- const state = generators.state();
415
+ const state = generators.state(); // this is the internal state backend <-> SSO provider
316
416
  const nonce = generators.nonce();
317
417
  const code_challenge = generators.codeChallenge(code_verifier);
318
418
  const expires = new Date(Date.now() + 1000 * 60 * 15);
@@ -323,9 +423,9 @@ export class SSOService {
323
423
  state,
324
424
  nonce,
325
425
  redirectUri,
326
- spaState,
426
+ spaState, // this is the state frontend <-> backend (not backend <-> SSO provider)
327
427
  providerType: this.provider,
328
- userId: Context.user?.id ?? null,
428
+ userId: user?.id ?? null,
329
429
  };
330
430
 
331
431
  try {
@@ -350,7 +450,7 @@ export class SSOService {
350
450
  state,
351
451
  nonce,
352
452
  prompt: prompt ?? undefined,
353
- login_hint: this.user?.email ?? undefined,
453
+ login_hint: user?.email ?? undefined,
354
454
  redirect_uri: this.externalRedirectUri,
355
455
 
356
456
  // Google has this instead of the offline_access scope