@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.
- package/package.json +10 -10
- package/src/endpoints/auth/OpenIDConnectAuthTokenEndpoint.ts +43 -0
- package/src/endpoints/auth/OpenIDConnectStartEndpoint.ts +6 -12
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +329 -1
- package/src/helpers/MemberUserSyncer.ts +90 -86
- package/src/services/FileSignService.ts +8 -18
- package/src/services/SSOService.ts +116 -16
- package/tests/e2e/register.test.ts +459 -24
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
91
|
+
File.getWithSignedUrl = async (file: File) => {
|
|
92
|
+
return this.withSignedUrl(file);
|
|
93
|
+
};
|
|
94
|
+
}
|
|
106
95
|
|
|
107
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
84
|
-
service.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|