@stamhoofd/backend 2.70.0 → 2.72.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.
@@ -1,295 +0,0 @@
1
- import { DecodedRequest, Response } from '@simonbackx/simple-endpoints';
2
- import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
3
- import { Organization, Token, User } from '@stamhoofd/models';
4
- import { LoginProviderType, OpenIDClientConfiguration, Token as TokenStruct } from '@stamhoofd/structures';
5
- import crypto from 'crypto';
6
- import { generators, Issuer } from 'openid-client';
7
-
8
- import { CookieHelper } from './CookieHelper';
9
-
10
- async function randomBytes(size: number): Promise<Buffer> {
11
- return new Promise((resolve, reject) => {
12
- crypto.randomBytes(size, (err: Error | null, buf: Buffer) => {
13
- if (err) {
14
- reject(err);
15
- return;
16
- }
17
- resolve(buf);
18
- });
19
- });
20
- }
21
-
22
- type SessionContext = {
23
- expires: Date;
24
- code_verifier: string;
25
- state: string;
26
- nonce: string;
27
- redirectUri: string;
28
- spaState: string;
29
- providerType: LoginProviderType;
30
- };
31
-
32
- export class OpenIDConnectHelper {
33
- organization: Organization | null;
34
- configuration: OpenIDClientConfiguration;
35
-
36
- static sessionStorage = new Map<string, SessionContext>();
37
-
38
- constructor(organization: Organization | null, configuration: OpenIDClientConfiguration) {
39
- this.organization = organization;
40
- this.configuration = configuration;
41
- }
42
-
43
- get redirectUri() {
44
- if (this.configuration.redirectUri) {
45
- return this.configuration.redirectUri;
46
- }
47
- // todo: we might need a special url for the app here
48
-
49
- if (!this.organization) {
50
- return 'https://' + STAMHOOFD.domains.api + '/openid/callback';
51
- }
52
- return 'https://' + this.organization.id + '.' + STAMHOOFD.domains.api + '/openid/callback';
53
- }
54
-
55
- async getClient() {
56
- const issuer = await Issuer.discover(this.configuration.issuer);
57
- const client = new issuer.Client({
58
- client_id: this.configuration.clientId,
59
- client_secret: this.configuration.clientSecret,
60
- redirect_uris: [this.redirectUri],
61
- response_types: ['code'],
62
- });
63
-
64
- // Todo: in the future we can add a cache here
65
-
66
- return client;
67
- }
68
-
69
- static async storeSession(response: Response<any>, data: SessionContext) {
70
- const sessionId = (await randomBytes(192)).toString('base64');
71
-
72
- // Delete expired sessions
73
- for (const [key, value] of this.sessionStorage) {
74
- if (value.expires < new Date()) {
75
- this.sessionStorage.delete(key);
76
- }
77
- }
78
-
79
- this.sessionStorage.set(sessionId, data);
80
-
81
- // Store
82
- CookieHelper.setCookie(response, 'oid_session_id', sessionId, {
83
- httpOnly: true,
84
- secure: STAMHOOFD.environment !== 'development',
85
- expires: data.expires,
86
- });
87
- }
88
-
89
- static getSession(request: DecodedRequest<any, any, any>): SessionContext | null {
90
- const sessionId = CookieHelper.getCookie(request, 'oid_session_id');
91
- if (!sessionId) {
92
- return null;
93
- }
94
-
95
- const session = this.sessionStorage.get(sessionId);
96
- if (!session) {
97
- return null;
98
- }
99
-
100
- if (session.expires < new Date()) {
101
- return null;
102
- }
103
-
104
- return session;
105
- }
106
-
107
- async startAuthCodeFlow(redirectUri: string, providerType: LoginProviderType, spaState: string, prompt: string | null = null): Promise<Response<undefined>> {
108
- const code_verifier = generators.codeVerifier();
109
- const state = generators.state();
110
- const nonce = generators.nonce();
111
- const code_challenge = generators.codeChallenge(code_verifier);
112
- const expires = new Date(Date.now() + 1000 * 60 * 15);
113
-
114
- const session: SessionContext = {
115
- expires,
116
- code_verifier,
117
- state,
118
- nonce,
119
- redirectUri,
120
- spaState,
121
- providerType,
122
- };
123
-
124
- try {
125
- const response = new Response(undefined);
126
-
127
- const client = await this.getClient();
128
- await OpenIDConnectHelper.storeSession(response, session);
129
-
130
- const redirect = client.authorizationUrl({
131
- scope: 'openid email profile',
132
- code_challenge,
133
- code_challenge_method: 'S256',
134
- response_mode: 'form_post',
135
- response_type: 'code',
136
- state,
137
- nonce,
138
- prompt: prompt ?? undefined,
139
- });
140
-
141
- response.headers['location'] = redirect;
142
- response.status = 302;
143
-
144
- return response;
145
- }
146
- catch (e) {
147
- const message = (isSimpleError(e) || isSimpleErrors(e) ? e.getHuman() : 'Er ging iets mis.');
148
- console.error('Error in openID callback', e);
149
- return OpenIDConnectHelper.getErrorRedirectResponse(session, message);
150
- }
151
- }
152
-
153
- async callback(request: DecodedRequest<any, any, any>): Promise<Response<undefined>> {
154
- const session = OpenIDConnectHelper.getSession(request);
155
-
156
- if (!session) {
157
- throw new Error('Missing session');
158
- }
159
-
160
- try {
161
- const response = new Response(undefined);
162
- const client = await this.getClient();
163
-
164
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
165
- const tokenSet = await client.callback(this.redirectUri, request.body, {
166
- code_verifier: session.code_verifier,
167
- state: session.state,
168
- nonce: session.nonce,
169
- });
170
-
171
- console.log('received and validated tokens %j', tokenSet);
172
-
173
- const claims = tokenSet.claims();
174
- console.log('validated ID Token claims %j', claims);
175
-
176
- if (!claims.name) {
177
- throw new SimpleError({
178
- code: 'invalid_user',
179
- message: 'Missing name',
180
- statusCode: 400,
181
- });
182
- }
183
-
184
- let firstName = claims.name.split(' ')[0];
185
- let lastName = claims.name.split(' ').slice(1).join(' ');
186
-
187
- // Get from API
188
- if (tokenSet.access_token) {
189
- const userinfo = await client.userinfo(tokenSet.access_token);
190
- console.log('userinfo', userinfo);
191
-
192
- if (userinfo.given_name) {
193
- console.log('userinfo given_name', userinfo.given_name);
194
- firstName = userinfo.given_name;
195
- }
196
-
197
- if (userinfo.family_name) {
198
- console.log('userinfo family_name', userinfo.family_name);
199
- lastName = userinfo.family_name;
200
- }
201
- }
202
-
203
- if (!claims.email) {
204
- throw new SimpleError({
205
- code: 'invalid_user',
206
- message: 'Missing email address',
207
- statusCode: 400,
208
- });
209
- }
210
-
211
- if (!claims.sub) {
212
- throw new SimpleError({
213
- code: 'invalid_user',
214
- message: 'Missing sub',
215
- statusCode: 400,
216
- });
217
- }
218
-
219
- // Get user from database
220
- let user = await User.getForRegister(this.organization?.id ?? null, claims.email);
221
- if (!user) {
222
- // Create a new user
223
- user = await User.registerSSO(this.organization, {
224
- id: undefined,
225
- email: claims.email,
226
- firstName,
227
- lastName,
228
- type: session.providerType,
229
- sub: claims.sub,
230
- });
231
-
232
- if (!user) {
233
- throw new SimpleError({
234
- code: 'invalid_user',
235
- message: 'Failed to create user',
236
- statusCode: 500,
237
- });
238
- }
239
- }
240
- else {
241
- // Update name
242
- if (!user.firstName || !user.hasPasswordBasedAccount()) {
243
- user.firstName = firstName;
244
- }
245
- if (!user.lastName || !user.hasPasswordBasedAccount()) {
246
- user.lastName = lastName;
247
- }
248
- user.linkLoginProvider(session.providerType, claims.sub);
249
- await user.save();
250
- }
251
-
252
- const token = await Token.createExpiredToken(user);
253
-
254
- if (!token) {
255
- throw new SimpleError({
256
- code: 'error',
257
- message: 'Could not generate token',
258
- human: 'Er ging iets mis bij het aanmelden',
259
- statusCode: 500,
260
- });
261
- }
262
-
263
- const st = new TokenStruct(token);
264
-
265
- // Redirect back to webshop
266
- const redirectUri = new URL(session.redirectUri);
267
- redirectUri.searchParams.set('oid_rt', st.refreshToken);
268
- redirectUri.searchParams.set('s', session.spaState);
269
-
270
- response.headers['location'] = redirectUri.toString();
271
- response.status = 302;
272
-
273
- return response;
274
- }
275
- catch (e) {
276
- const message = (isSimpleError(e) || isSimpleErrors(e) ? e.getHuman() : 'Er ging iets mis.');
277
- console.error('Error in openID callback', e);
278
- return OpenIDConnectHelper.getErrorRedirectResponse(session, message);
279
- }
280
- }
281
-
282
- static getErrorRedirectResponse(session: SessionContext, errorMessage: string) {
283
- const response = new Response(undefined);
284
-
285
- // Redirect back to webshop
286
- const redirectUri = new URL(session.redirectUri);
287
- redirectUri.searchParams.set('s', session.spaState);
288
- redirectUri.searchParams.set('error', errorMessage);
289
-
290
- response.headers['location'] = redirectUri.toString();
291
- response.status = 302;
292
-
293
- return response;
294
- }
295
- }