@stamhoofd/backend 2.143.1 → 2.145.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 (111) hide show
  1. package/package.json +19 -19
  2. package/src/crons/fake-settlements.test.ts +13 -11
  3. package/src/crons/fake-settlements.ts +22 -38
  4. package/src/crons/mollie-chargebacks.ts +31 -29
  5. package/src/crons/mollie-refunds.ts +61 -59
  6. package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +16 -5
  7. package/src/endpoints/auth/CreateTokenEndpoint.test.ts +210 -4
  8. package/src/endpoints/auth/CreateTokenEndpoint.ts +9 -20
  9. package/src/endpoints/auth/DeleteTokenEndpoint.test.ts +29 -0
  10. package/src/endpoints/auth/DeleteTokenEndpoint.ts +2 -1
  11. package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -11
  12. package/src/endpoints/auth/MFA.security.test.ts +9 -7
  13. package/src/endpoints/auth/MFA.test.ts +42 -15
  14. package/src/endpoints/auth/PatchUserEndpoint.ts +3 -2
  15. package/src/endpoints/auth/VerifyEmailEndpoint.ts +5 -4
  16. package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +4 -3
  17. package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +6 -5
  18. package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +5 -4
  19. package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +5 -4
  20. package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +2 -1
  21. package/src/endpoints/global/events/PatchEventsEndpoint.test.ts +2 -1
  22. package/src/endpoints/global/files/UploadFile.test.ts +2 -1
  23. package/src/endpoints/global/files/UploadFile.ts +53 -25
  24. package/src/endpoints/global/files/UploadImage.test.ts +2 -1
  25. package/src/endpoints/global/files/UploadImage.ts +32 -6
  26. package/src/endpoints/global/files/upload-security.test.ts +3 -2
  27. package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +4 -3
  28. package/src/endpoints/global/members/GetMembersEndpoint.test.ts +26 -25
  29. package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +57 -56
  30. package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +4 -3
  31. package/src/endpoints/global/platform/GetPlatformEndpoint.test.ts +6 -4
  32. package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +6 -6
  33. package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +9 -8
  34. package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +14 -13
  35. package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +24 -23
  36. package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +4 -3
  37. package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.test.ts +2 -1
  38. package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +2 -1
  39. package/src/endpoints/global/webshops/GetWebshopsEndpoint.test.ts +20 -19
  40. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +2 -1
  41. package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +2 -1
  42. package/src/endpoints/organization/dashboard/billing/ApplyRegisterCodeEndpoint.test.ts +3 -2
  43. package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.test.ts +4 -3
  44. package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +5 -4
  45. package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +5 -4
  46. package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.test.ts +2 -1
  47. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.test.ts +2 -1
  48. package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +0 -1
  49. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +23 -22
  50. package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +4 -4
  51. package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +2 -1
  52. package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +4 -3
  53. package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +2 -2
  54. package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.test.ts +2 -1
  55. package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +5 -4
  56. package/src/endpoints/organization/dashboard/registration-periods/MoveRegistrationPeriods.test.ts +7 -6
  57. package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +9 -8
  58. package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +3 -2
  59. package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +2 -1
  60. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +5 -4
  61. package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +2 -1
  62. package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +3 -2
  63. package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +6 -5
  64. package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +16 -15
  65. package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +2 -1
  66. package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +2 -1
  67. package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +2 -1
  68. package/src/endpoints/organization/dashboard/webshops/WebshopReservedPathSegments.test.ts +2 -1
  69. package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +4 -3
  70. package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +6 -5
  71. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +94 -8
  72. package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +18 -18
  73. package/src/helpers/ApplicationFeeInvoicer.ts +1 -1
  74. package/src/helpers/Context.ts +14 -21
  75. package/src/helpers/ContextStorage.ts +5 -0
  76. package/src/helpers/MollieSettlementSync.test.ts +94 -41
  77. package/src/helpers/MollieSettlementSync.ts +174 -78
  78. package/src/helpers/StripeSettlementSync.test.ts +0 -85
  79. package/src/helpers/StripeSettlementSync.ts +47 -70
  80. package/src/helpers/TwoFactorHelper.ts +21 -22
  81. package/src/helpers/streamForBreakdown.ts +1 -1
  82. package/src/services/AdminSessionService.ts +5 -3
  83. package/src/services/InvoiceService.ts +2 -2
  84. package/src/services/MollieService.ts +168 -126
  85. package/src/services/PaymentService.ts +2 -2
  86. package/src/services/SSOService.test.ts +25 -1
  87. package/src/services/SSOService.ts +17 -6
  88. package/src/services/SessionService.test.ts +319 -0
  89. package/src/services/SessionService.ts +357 -0
  90. package/src/services/SettlementService.test.ts +13 -85
  91. package/src/services/SettlementService.ts +5 -113
  92. package/src/services/WebshopCrowdfundingService.test.ts +4 -3
  93. package/src/sql-filters/balance-items.ts +10 -0
  94. package/src/sql-filters/registrations.ts +59 -1
  95. package/tests/actions/patchUserMember.ts +2 -1
  96. package/tests/e2e/api-rate-limits.test.ts +2 -1
  97. package/tests/e2e/bundle-discounts.test.ts +4 -3
  98. package/tests/e2e/charge-members.test.ts +6 -5
  99. package/tests/e2e/documents.test.ts +4 -3
  100. package/tests/e2e/private-files.test.ts +9 -8
  101. package/tests/e2e/register.test.ts +4 -3
  102. package/tests/e2e/stock.test.ts +4 -3
  103. package/tests/e2e/tickets.test.ts +4 -3
  104. package/tests/helpers/ExportSlice.ts +2 -1
  105. package/tests/helpers/MollieMocker.ts +173 -38
  106. package/tests/init/initAdmin.ts +2 -1
  107. package/tests/init/initPlatformAdmin.ts +2 -1
  108. package/src/helpers/ViesHelper.test.ts +0 -401
  109. package/src/helpers/ViesHelper.ts +0 -215
  110. package/src/services/PeppolDirectoryService.test.ts +0 -139
  111. package/src/services/PeppolDirectoryService.ts +0 -111
@@ -1,6 +1,9 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
- import { OrganizationFactory, Token, User, UserFactory } from '@stamhoofd/models';
3
- import { Token as TokenStruct } from '@stamhoofd/structures';
2
+ import type { Organization } from '@stamhoofd/models';
3
+ import { OrganizationFactory, Token, User, UserFactory, UserSession } from '@stamhoofd/models';
4
+ import { SESSION_DURATIONS } from '@stamhoofd/models/constants/sessions.js';
5
+ import { PermissionLevel, Permissions, SessionClientType, Token as TokenStruct } from '@stamhoofd/structures';
6
+ import { SessionService } from '../../services/SessionService.js';
4
7
 
5
8
  import { testServer } from '../../../tests/helpers/TestServer.js';
6
9
  import { CreateTokenEndpoint } from './CreateTokenEndpoint.js';
@@ -41,7 +44,7 @@ describe('Endpoint.CreateToken', () => {
41
44
  // Also check UTF8 passwords
42
45
  const password = '54😂test👌🏾86s&é';
43
46
  const user = await new UserFactory({ organization, password }).create();
44
- const token = await Token.createToken(user);
47
+ const token = await SessionService.createSession(user);
45
48
 
46
49
  const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
47
50
  grant_type: 'refresh_token',
@@ -94,7 +97,7 @@ describe('Endpoint.CreateToken', () => {
94
97
  test('refreshing a token marks the user as active again', async () => {
95
98
  const organization = await new OrganizationFactory({}).create();
96
99
  const user = await new UserFactory({ organization, password }).create();
97
- const token = await Token.createToken(user);
100
+ const token = await SessionService.createSession(user);
98
101
 
99
102
  const monthsAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
100
103
  monthsAgo.setMilliseconds(0);
@@ -125,4 +128,207 @@ describe('Endpoint.CreateToken', () => {
125
128
  expect(await getLastActiveAt(user)).toBeNull();
126
129
  });
127
130
  });
131
+
132
+ describe('session length', () => {
133
+ const password = 'test-password-1234';
134
+ const DAY = 24 * 60 * 60 * 1000;
135
+
136
+ function expectValidFor(date: Date, duration: number) {
137
+ expect(date.getTime()).toBeGreaterThan(Date.now() + duration - 5000);
138
+ expect(date.getTime()).toBeLessThanOrEqual(Date.now() + duration);
139
+ }
140
+
141
+ async function login(organization: Organization, user: User, platform: string | null = null): Promise<Token> {
142
+ const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
143
+ grant_type: 'password',
144
+ username: user.email,
145
+ password,
146
+ });
147
+ if (platform) {
148
+ r.headers['x-platform'] = platform;
149
+ }
150
+
151
+ const response = await testServer.test(endpoint, r);
152
+ if (!(response.body instanceof TokenStruct)) {
153
+ throw new Error('Expected TokenStruct');
154
+ }
155
+
156
+ const token = await Token.getByAccessToken(response.body.accessToken);
157
+ expect(token).toBeDefined();
158
+ return token!;
159
+ }
160
+
161
+ async function refresh(organization: Organization, refreshToken: string): Promise<Token> {
162
+ const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
163
+ grant_type: 'refresh_token',
164
+ refresh_token: refreshToken,
165
+ });
166
+
167
+ const response = await testServer.test(endpoint, r);
168
+ if (!(response.body instanceof TokenStruct)) {
169
+ throw new Error('Expected TokenStruct');
170
+ }
171
+
172
+ const token = await Token.getByAccessToken(response.body.accessToken);
173
+ expect(token).toBeDefined();
174
+ return token!;
175
+ }
176
+
177
+ async function getSession(token: Token): Promise<UserSession> {
178
+ const session = await UserSession.getByID(token.sessionId);
179
+ if (!session) throw new Error('Expected user session');
180
+ return session;
181
+ }
182
+
183
+ test('an administrator that signs in in a browser gets a limited session', async () => {
184
+ const organization = await new OrganizationFactory({}).create();
185
+ const admin = await new UserFactory({
186
+ organization,
187
+ password,
188
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
189
+ }).create();
190
+
191
+ const token = await login(organization, admin, 'web');
192
+
193
+ expect((await getSession(token)).clientType).toBe(SessionClientType.Browser);
194
+ expectValidFor(token.refreshTokenValidUntil, SESSION_DURATIONS.admin[SessionClientType.Browser].refreshToken);
195
+ });
196
+
197
+ test('an administrator that signs in in the native app has no maximum session length', async () => {
198
+ const organization = await new OrganizationFactory({}).create();
199
+ const admin = await new UserFactory({
200
+ organization,
201
+ password,
202
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
203
+ }).create();
204
+
205
+ const token = await login(organization, admin, 'ios');
206
+
207
+ expect((await getSession(token)).clientType).toBe(SessionClientType.iOS);
208
+ expectValidFor(token.refreshTokenValidUntil, SESSION_DURATIONS.admin[SessionClientType.iOS].refreshToken);
209
+
210
+ const browserSessionDuration = SESSION_DURATIONS.admin[SessionClientType.Browser].session;
211
+ if (browserSessionDuration === null) {
212
+ throw new Error('Expected browser administrator sessions to have a maximum length');
213
+ }
214
+ const userSession = await getSession(token);
215
+ userSession.startedAt = new Date(Date.now() - 2 * browserSessionDuration);
216
+ await userSession.save();
217
+
218
+ const renewed = await refresh(organization, token.refreshToken);
219
+ expectValidFor(renewed.refreshTokenValidUntil, SESSION_DURATIONS.admin[SessionClientType.iOS].refreshToken);
220
+ });
221
+
222
+ test('renewing the same active token twice revokes the first replacement', async () => {
223
+ const organization = await new OrganizationFactory({}).create();
224
+ const user = await new UserFactory({ organization, password }).create();
225
+ const original = await login(organization, user);
226
+
227
+ const firstReplacement = await refresh(organization, original.refreshToken);
228
+ const secondReplacement = await refresh(organization, original.refreshToken);
229
+
230
+ expect(secondReplacement.sessionId).toBe(original.sessionId);
231
+ expect(await Token.getByAccessToken(firstReplacement.accessToken)).toBeUndefined();
232
+ expect(await Token.getByAccessToken(original.accessToken)).toBeDefined();
233
+ expect(await Token.getByAccessToken(secondReplacement.accessToken)).toBeDefined();
234
+
235
+ const sessionTokens = await Token.where({ sessionId: original.sessionId });
236
+ expect(sessionTokens.map(token => token.id).sort()).toEqual([original.id, secondReplacement.id].sort());
237
+
238
+ const forkRequest = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
239
+ grant_type: 'refresh_token',
240
+ refresh_token: firstReplacement.refreshToken,
241
+ });
242
+ await expect(testServer.test(endpoint, forkRequest)).rejects.toMatchObject({ code: 'invalid_refresh_token' });
243
+ });
244
+
245
+ test('an SSO handoff refresh token can only be exchanged once', async () => {
246
+ const organization = await new OrganizationFactory({}).create();
247
+ const user = await new UserFactory({ organization }).create();
248
+ const handoff = await SessionService.createSSOHandoff(user);
249
+
250
+ const sessionToken = await refresh(organization, handoff.refreshToken);
251
+ expect(sessionToken.sessionId).toBe(handoff.sessionId);
252
+
253
+ await expect(refresh(organization, handoff.refreshToken)).rejects.toMatchObject({
254
+ code: 'invalid_refresh_token',
255
+ });
256
+ });
257
+
258
+ test('concurrent exchanges cannot fork an SSO handoff', async () => {
259
+ const organization = await new OrganizationFactory({}).create();
260
+ const user = await new UserFactory({ organization }).create();
261
+ const handoff = await SessionService.createSSOHandoff(user);
262
+
263
+ const exchanges = await Promise.allSettled([
264
+ refresh(organization, handoff.refreshToken),
265
+ refresh(organization, handoff.refreshToken),
266
+ ]);
267
+
268
+ expect(exchanges.filter(result => result.status === 'fulfilled')).toHaveLength(1);
269
+ const rejected = exchanges.filter(result => result.status === 'rejected');
270
+ expect(rejected).toHaveLength(1);
271
+ expect(rejected[0].reason).toMatchObject({ code: 'invalid_refresh_token' });
272
+ });
273
+
274
+ test('renewing an access token does not extend the session past its maximum length', async () => {
275
+ const organization = await new OrganizationFactory({}).create();
276
+ const admin = await new UserFactory({
277
+ organization,
278
+ password,
279
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
280
+ }).create();
281
+
282
+ const token = await login(organization, admin, 'web');
283
+
284
+ const sessionDuration = SESSION_DURATIONS.admin[SessionClientType.Browser].session;
285
+ if (sessionDuration === null) {
286
+ throw new Error('Expected browser administrator sessions to have a maximum length');
287
+ }
288
+ const sessionStartedAt = new Date(Date.now() - sessionDuration + DAY);
289
+ sessionStartedAt.setMilliseconds(0);
290
+ const userSession = await getSession(token);
291
+ userSession.startedAt = sessionStartedAt;
292
+ await userSession.save();
293
+
294
+ const renewed = await refresh(organization, token.refreshToken);
295
+
296
+ expect((await getSession(renewed)).startedAt).toEqual(sessionStartedAt);
297
+ expect(renewed.refreshTokenValidUntil.getTime()).toBeLessThanOrEqual(sessionStartedAt.getTime() + sessionDuration);
298
+ expect(renewed.refreshTokenValidUntil.getTime()).toBeGreaterThan(Date.now());
299
+ });
300
+
301
+ test('a session that reached its maximum length cannot be renewed', async () => {
302
+ const organization = await new OrganizationFactory({}).create();
303
+ const admin = await new UserFactory({
304
+ organization,
305
+ password,
306
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
307
+ }).create();
308
+
309
+ const token = await login(organization, admin, 'web');
310
+ const sessionDuration = SESSION_DURATIONS.admin[SessionClientType.Browser].session;
311
+ if (sessionDuration === null) {
312
+ throw new Error('Expected browser administrator sessions to have a maximum length');
313
+ }
314
+ const userSession = await getSession(token);
315
+ userSession.startedAt = new Date(Date.now() - sessionDuration - DAY);
316
+ await userSession.save();
317
+ token.refreshTokenValidUntil = new Date(Date.now() + DAY);
318
+ await token.save();
319
+
320
+ const otherSession = await login(organization, admin, 'web');
321
+
322
+ const r = Request.buildJson('POST', '/oauth/token', organization.getApiHost(), {
323
+ grant_type: 'refresh_token',
324
+ refresh_token: token.refreshToken,
325
+ });
326
+
327
+ await expect(testServer.test(endpoint, r)).rejects.toMatchObject({ code: 'invalid_refresh_token' });
328
+ expect(await Token.getByAccessToken(otherSession.accessToken)).toBeDefined();
329
+
330
+ await expect(testServer.test(endpoint, r)).rejects.toMatchObject({ code: 'invalid_refresh_token' });
331
+ expect(await Token.getByAccessToken(otherSession.accessToken)).toBeDefined();
332
+ });
333
+ });
128
334
  });
@@ -3,13 +3,14 @@ import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
3
  import { SimpleError } from '@simonbackx/simple-errors';
4
4
  import { EmailVerificationCode, MFATOTP, MFAToken, PasswordToken, Platform, Token, User, WebauthnCredential } from '@stamhoofd/models';
5
5
  import type { ChallengeGrantStruct, MFAGrantStruct, PasswordGrantStruct, PasswordTokenGrantStruct, RefreshTokenGrantStruct, RequestChallengeGrantStruct } from '@stamhoofd/structures';
6
- import { CreateTokenStruct, LoginMethod, MFAMethodType, SignupResponse, Token as TokenStruct } from '@stamhoofd/structures';
6
+ import { CreateTokenStruct, LoginMethod, MFAMethodType, SessionLoginMethod, SignupResponse, Token as TokenStruct } from '@stamhoofd/structures';
7
7
 
8
8
  import { Context } from '../../helpers/Context.js';
9
9
  import { RecoveryCodeHelper } from '../../helpers/RecoveryCodeHelper.js';
10
10
  import { TOTPHelper } from '../../helpers/TOTPHelper.js';
11
11
  import { mfaVerificationRateLimiter, TwoFactorHelper } from '../../helpers/TwoFactorHelper.js';
12
12
  import { WebauthnHelper } from '../../helpers/WebauthnHelper.js';
13
+ import { SessionService } from '../../services/SessionService.js';
13
14
  import { VerificationCodeService } from '../../services/VerificationCodeService.js';
14
15
 
15
16
  type Params = Record<string, never>;
@@ -45,7 +46,7 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
45
46
 
46
47
  switch (request.body.grantType) {
47
48
  case 'refresh_token': {
48
- const oldToken = await Token.getByRefreshToken(request.body.refreshToken);
49
+ const oldToken = await SessionService.getByRefreshToken(request.body.refreshToken);
49
50
  if (!oldToken) {
50
51
  throw new SimpleError({
51
52
  code: 'invalid_refresh_token',
@@ -63,19 +64,7 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
63
64
  });
64
65
  }
65
66
 
66
- // Important to create a new token before adjusting the old token
67
- const token = await Token.createToken(oldToken.user);
68
-
69
- // In the rare event our response doesn't reach the client anymore, we don't want the client to sign out...
70
- // So we allow a small rotation overlap period
71
- const leeway = 60 * 1000;
72
- oldToken.refreshTokenValidUntil = new Date(Math.min(oldToken.refreshTokenValidUntil.getTime(), Date.now() + leeway));
73
-
74
- // Invalidate the corresponding access token
75
- oldToken.accessTokenValidUntil = new Date(Date.now() - 60 * 60 * 1000);
76
-
77
- // Do not delete the old one, only expire it fast so it will get deleted in the future
78
- await oldToken.save();
67
+ const token = await SessionService.rotateSession(oldToken);
79
68
 
80
69
  if (!token) {
81
70
  throw new SimpleError({
@@ -150,9 +139,9 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
150
139
  // Second factor / forced enrollment: block the session until the user
151
140
  // completes (or first sets up) a second factor. Shared with every other
152
141
  // grant that mints a session from a single primary credential.
153
- await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion(), { loginMethod: 'password', i18n: request.i18n });
142
+ await TwoFactorHelper.assertSecondFactorOrThrow(user, organization, request.request.getVersion(), { loginMethod: SessionLoginMethod.Password, i18n: request.i18n });
154
143
 
155
- const token = await Token.createToken(user, new Date());
144
+ const token = await SessionService.createSession(user, { loginMethod: SessionLoginMethod.Password, authenticatedAt: new Date() });
156
145
 
157
146
  if (!token) {
158
147
  throw new SimpleError({
@@ -264,7 +253,7 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
264
253
  });
265
254
  }
266
255
 
267
- const token = await Token.createToken(user, new Date());
256
+ const token = await SessionService.createSession(user, { loginMethod: mfaToken.loginMethod, authenticatedAt: new Date() });
268
257
  await user.markActive();
269
258
 
270
259
  const st = new TokenStruct(token);
@@ -314,10 +303,10 @@ export class CreateTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
314
303
  //
315
304
  // The link was emailed to the account, so this is also how a long-inactive
316
305
  // admin confirms their email address and gets to enroll after all.
317
- await TwoFactorHelper.assertSecondFactorOrThrow(passwordToken.user, organization, request.request.getVersion(), { loginMethod: 'email', i18n: request.i18n, allowTemporarySession: true });
306
+ await TwoFactorHelper.assertSecondFactorOrThrow(passwordToken.user, organization, request.request.getVersion(), { loginMethod: SessionLoginMethod.Email, i18n: request.i18n, allowTemporarySession: true });
318
307
 
319
308
  // Important to create a new token before adjusting the old token
320
- const token = await Token.createToken(passwordToken.user, new Date());
309
+ const token = await SessionService.createSession(passwordToken.user, { loginMethod: SessionLoginMethod.Email, authenticatedAt: new Date() });
321
310
 
322
311
  // TODO: make token short lived until renewal
323
312
 
@@ -0,0 +1,29 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import { OrganizationFactory, Token, UserFactory, UserSession } from '@stamhoofd/models';
3
+
4
+ import { testServer } from '../../../tests/helpers/TestServer.js';
5
+ import { SessionService } from '../../services/SessionService.js';
6
+ import { DeleteTokenEndpoint } from './DeleteTokenEndpoint.js';
7
+
8
+ describe('Endpoint.DeleteToken', () => {
9
+ const endpoint = new DeleteTokenEndpoint();
10
+
11
+ test('ends the session and deletes every token in it', async () => {
12
+ const organization = await new OrganizationFactory({}).create();
13
+ const user = await new UserFactory({ organization }).create();
14
+ const original = await SessionService.createSession(user);
15
+ const renewed = await SessionService.rotateSession(original);
16
+ const otherSession = await SessionService.createSession(user);
17
+
18
+ const request = Request.buildJson('DELETE', '/oauth/token', organization.getApiHost());
19
+ request.headers.authorization = 'Bearer ' + original.accessToken;
20
+ await testServer.test(endpoint, request);
21
+
22
+ expect(await UserSession.getByID(original.sessionId)).toBeUndefined();
23
+ expect(await Token.getByAccessToken(original.accessToken, true)).toBeUndefined();
24
+ expect(await Token.getByAccessToken(renewed.accessToken, true)).toBeUndefined();
25
+ expect(await UserSession.getByID(otherSession.sessionId)).toBeDefined();
26
+ expect(await Token.getByAccessToken(otherSession.accessToken, true)).toBeDefined();
27
+ });
28
+
29
+ });
@@ -2,6 +2,7 @@ import type { DecodedRequest, Request } from '@simonbackx/simple-endpoints';
2
2
  import { Endpoint, Response } from '@simonbackx/simple-endpoints';
3
3
 
4
4
  import { Context } from '../../helpers/Context.js';
5
+ import { SessionService } from '../../services/SessionService.js';
5
6
 
6
7
  type Params = Record<string, never>;
7
8
  type Query = undefined;
@@ -25,7 +26,7 @@ export class DeleteTokenEndpoint extends Endpoint<Params, Query, Body, ResponseB
25
26
  async handle(_: DecodedRequest<Params, Query, Body>) {
26
27
  await Context.setOptionalOrganizationScope();
27
28
  const { token } = await Context.authenticate({ allowWithoutAccount: true });
28
- await token.delete();
29
+ await SessionService.endSession(token);
29
30
 
30
31
  return new Response(undefined);
31
32
  }
@@ -3,6 +3,7 @@ import { MemberFactory, OrganizationFactory, Token, UserFactory } from '@stamhoo
3
3
 
4
4
  import { MFATestHelper } from '../../../tests/helpers/MFATestHelper.js';
5
5
  import { testServer } from '../../../tests/helpers/TestServer.js';
6
+ import { SessionService } from '../../services/SessionService.js';
6
7
  import { GetUserEndpoint } from './GetUserEndpoint.js';
7
8
  import { STExpect } from '@stamhoofd/test-utils';
8
9
 
@@ -13,7 +14,7 @@ describe('Endpoint.GetUser', () => {
13
14
  test('Request user details when signed in', async () => {
14
15
  const organization = await new OrganizationFactory({}).create();
15
16
  const user = await new UserFactory({ organization }).create();
16
- const token = await Token.createToken(user);
17
+ const token = await SessionService.createSession(user);
17
18
 
18
19
  const r = Request.buildJson('GET', '/v1/user', organization.getApiHost());
19
20
  r.headers.authorization = 'Bearer ' + token.accessToken;
@@ -23,13 +24,32 @@ describe('Endpoint.GetUser', () => {
23
24
  expect(response.body.id).toEqual(user.id);
24
25
  });
25
26
 
27
+ test('using a renewed token retires the previous active token', async () => {
28
+ const organization = await new OrganizationFactory({}).create();
29
+ const user = await new UserFactory({ organization }).create();
30
+ const original = await SessionService.createSession(user);
31
+ const replacement = await SessionService.rotateSession(original);
32
+
33
+ const originalRequest = Request.buildJson('GET', '/v1/user', organization.getApiHost());
34
+ originalRequest.headers.authorization = 'Bearer ' + original.accessToken;
35
+ await testServer.test(endpoint, originalRequest);
36
+
37
+ const replacementRequest = Request.buildJson('GET', '/v1/user', organization.getApiHost());
38
+ replacementRequest.headers.authorization = 'Bearer ' + replacement.accessToken;
39
+ await testServer.test(endpoint, replacementRequest);
40
+
41
+ await expect(testServer.test(endpoint, originalRequest)).rejects.toThrow(STExpect.simpleError({
42
+ code: 'invalid_access_token',
43
+ }));
44
+ });
45
+
26
46
  test('Two-factor authentication is reported on the user and on the accounts of a member', async () => {
27
47
  const organization = await new OrganizationFactory({}).create();
28
48
  const user = await new UserFactory({ organization }).create();
29
49
  await new MemberFactory({ organization, user }).create();
30
50
  await MFATestHelper.addConfirmedTOTP(user);
31
51
 
32
- const token = await Token.createToken(user);
52
+ const token = await SessionService.createSession(user);
33
53
  const r = Request.buildJson('GET', '/v1/user', organization.getApiHost());
34
54
  r.headers.authorization = 'Bearer ' + token.accessToken;
35
55
 
@@ -44,7 +64,7 @@ describe('Endpoint.GetUser', () => {
44
64
  test('A user without a second factor is not reported as having two-factor authentication', async () => {
45
65
  const organization = await new OrganizationFactory({}).create();
46
66
  const user = await new UserFactory({ organization }).create();
47
- const token = await Token.createToken(user);
67
+ const token = await SessionService.createSession(user);
48
68
 
49
69
  const r = Request.buildJson('GET', '/v1/user', organization.getApiHost());
50
70
  r.headers.authorization = 'Bearer ' + token.accessToken;
@@ -62,7 +82,7 @@ describe('Endpoint.GetUser', () => {
62
82
  test('Request user details with invalid token is not working', async () => {
63
83
  const organization = await new OrganizationFactory({}).create();
64
84
  const user = await new UserFactory({ organization }).create();
65
- const token = await Token.createToken(user);
85
+ const token = await SessionService.createSession(user);
66
86
 
67
87
  const r = Request.buildJson('GET', '/v1/user', organization.getApiHost());
68
88
  r.headers.authorization = 'Bearer ' + token.accessToken + 'd';
@@ -73,7 +93,9 @@ describe('Endpoint.GetUser', () => {
73
93
  test('Request user details with expired token is not working', async () => {
74
94
  const organization = await new OrganizationFactory({}).create();
75
95
  const user = await new UserFactory({ organization }).create();
76
- const token = await Token.createExpiredToken(user);
96
+ const token = await SessionService.createSession(user);
97
+ token.accessTokenValidUntil = new Date(Date.now() - 1000);
98
+ await token.save();
77
99
 
78
100
  const r = Request.buildJson('GET', '/v1/user', organization.getApiHost());
79
101
  r.headers.authorization = 'Bearer ' + token.accessToken;
@@ -84,7 +106,7 @@ describe('Endpoint.GetUser', () => {
84
106
  test('Can request details of an API user by token without knowing the organization scope', async () => {
85
107
  const organization = await new OrganizationFactory({}).create();
86
108
  const user = await new UserFactory({ organization, apiUser: true }).create();
87
- const token = await Token.createToken(user);
109
+ const token = await SessionService.createSession(user);
88
110
 
89
111
  const r = Request.buildJson('GET', '/v1/user');
90
112
  r.headers.authorization = 'Bearer ' + token.accessToken;
@@ -98,13 +120,13 @@ describe('Endpoint.GetUser', () => {
98
120
  test('Cannot request details of an normal user by token without knowing the organization scope', async () => {
99
121
  const organization = await new OrganizationFactory({}).create();
100
122
  const user = await new UserFactory({ organization }).create();
101
- const token = await Token.createToken(user);
123
+ const token = await SessionService.createSession(user);
102
124
 
103
125
  const r = Request.buildJson('GET', '/v1/user');
104
126
  r.headers.authorization = 'Bearer ' + token.accessToken;
105
127
 
106
128
  await expect(testServer.test(endpoint, r)).rejects.toThrow(STExpect.simpleError({
107
- code: 'invalid_access_token'
129
+ code: 'invalid_access_token',
108
130
  }));
109
131
  });
110
132
 
@@ -112,14 +134,13 @@ describe('Endpoint.GetUser', () => {
112
134
  const wrongOrganization = await new OrganizationFactory({}).create();
113
135
  const organization = await new OrganizationFactory({}).create();
114
136
  const user = await new UserFactory({ organization, apiUser: true }).create();
115
- const token = await Token.createToken(user);
137
+ const token = await SessionService.createSession(user);
116
138
 
117
139
  const r = Request.buildJson('GET', '/v1/user', wrongOrganization.getApiHost());
118
140
  r.headers.authorization = 'Bearer ' + token.accessToken;
119
141
 
120
142
  await expect(testServer.test(endpoint, r)).rejects.toThrow(STExpect.simpleError({
121
- code: 'invalid_access_token'
143
+ code: 'invalid_access_token',
122
144
  }));
123
145
  });
124
-
125
146
  });
@@ -1,10 +1,13 @@
1
1
  import { Request } from '@simonbackx/simple-endpoints';
2
- import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
3
- import { EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, Organization, OrganizationFactory, PasswordToken, Token, User, UserFactory, WebauthnCredential } from '@stamhoofd/models';
2
+ import type { SimpleError } from '@simonbackx/simple-errors';
3
+ import { isSimpleError, isSimpleErrors } from '@simonbackx/simple-errors';
4
+ import type { Organization } from '@stamhoofd/models';
5
+ import { EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, OrganizationFactory, PasswordToken, Token, User, UserFactory, WebauthnCredential } from '@stamhoofd/models';
4
6
  import { NewUser, PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
5
7
  import { TestUtils } from '@stamhoofd/test-utils';
6
8
  import crypto from 'crypto';
7
9
  import { authenticator } from 'otplib';
10
+ import { SessionService } from '../../services/SessionService.js';
8
11
 
9
12
  import { MFATestHelper } from '../../../tests/helpers/MFATestHelper.js';
10
13
  import { testServer } from '../../../tests/helpers/TestServer.js';
@@ -49,8 +52,7 @@ function firstError(e: unknown): SimpleError {
49
52
  async function captureError(promise: Promise<unknown>): Promise<SimpleError> {
50
53
  try {
51
54
  await promise;
52
- }
53
- catch (e) {
55
+ } catch (e) {
54
56
  return firstError(e);
55
57
  }
56
58
  throw new Error('Expected the request to be rejected, but it succeeded');
@@ -79,7 +81,7 @@ function withSetup(request: Request, setupToken: string) {
79
81
  }
80
82
 
81
83
  async function freshToken(user: User): Promise<Token> {
82
- return await Token.createToken(user, new Date());
84
+ return await SessionService.createSession(user, { authenticatedAt: new Date() });
83
85
  }
84
86
 
85
87
  async function addConfirmedTOTP(user: User): Promise<{ id: string; secret: string }> {
@@ -649,9 +651,9 @@ describe('MFA security', () => {
649
651
  // separately and are not browser sessions.
650
652
  const organization = await new OrganizationFactory({}).create();
651
653
  const apiUser = await new UserFactory({ organization, apiUser: true }).create();
652
- const apiToken = await Token.createToken(apiUser);
654
+ const apiToken = await SessionService.createSession(apiUser);
653
655
 
654
- expect(await Token.deleteOtherSessions(apiUser.id, null)).toBe(0);
656
+ expect(await SessionService.deleteOtherSessions({ userId: apiUser.id, keepAccessToken: null })).toBe(0);
655
657
  expect(await Token.getByAccessToken(apiToken.accessToken, true)).toBeDefined();
656
658
  });
657
659
  });
@@ -1,10 +1,14 @@
1
- import { Endpoint, Request } from '@simonbackx/simple-endpoints';
2
- import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
1
+ import type { Endpoint } from '@simonbackx/simple-endpoints';
2
+ import { Request } from '@simonbackx/simple-endpoints';
3
+ import type { SimpleError } from '@simonbackx/simple-errors';
4
+ import { isSimpleError, isSimpleErrors } from '@simonbackx/simple-errors';
3
5
  import { EmailMocker } from '@stamhoofd/email';
4
- import { AuditLog, EmailTemplateFactory, EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, Organization, PasswordToken, Platform, Token, User, UserFactory, OrganizationFactory, WebauthnChallenge, WebauthnCredential } from '@stamhoofd/models';
5
- import { AuditLogReplacementType, AuditLogType, EmailTemplateType, MFAMethodType, PermissionLevel, Permissions, Token as TokenStruct } from '@stamhoofd/structures';
6
+ import type { Organization } from '@stamhoofd/models';
7
+ import { AuditLog, EmailTemplateFactory, EmailVerificationCode, MFARecoveryCode, MFATOTP, MFAToken, PasswordToken, Platform, Token, User, UserFactory, OrganizationFactory, UserSession, WebauthnChallenge, WebauthnCredential } from '@stamhoofd/models';
8
+ import { AuditLogReplacementType, AuditLogType, EmailTemplateType, MFAMethodType, PermissionLevel, Permissions, SessionLoginMethod, Token as TokenStruct } from '@stamhoofd/structures';
6
9
  import { authenticator } from 'otplib';
7
10
  import crypto from 'crypto';
11
+ import { SessionService } from '../../services/SessionService.js';
8
12
 
9
13
  import { MFATestHelper } from '../../../tests/helpers/MFATestHelper.js';
10
14
  import { testServer } from '../../../tests/helpers/TestServer.js';
@@ -43,8 +47,7 @@ function firstError(e: unknown): SimpleError {
43
47
  async function captureError(promise: Promise<unknown>): Promise<SimpleError> {
44
48
  try {
45
49
  await promise;
46
- }
47
- catch (e) {
50
+ } catch (e) {
48
51
  return firstError(e);
49
52
  }
50
53
  throw new Error('Expected the request to throw');
@@ -106,12 +109,12 @@ function bearer(request: Request, token: Token) {
106
109
  }
107
110
 
108
111
  async function freshToken(user: User): Promise<Token> {
109
- return await Token.createToken(user, new Date());
112
+ return await SessionService.createSession(user, { authenticatedAt: new Date() });
110
113
  }
111
114
 
112
115
  async function staleToken(user: User): Promise<Token> {
113
116
  // A token as produced by a refresh_token rotation (never authenticatedAt).
114
- return await Token.createToken(user);
117
+ return await SessionService.createSession(user);
115
118
  }
116
119
 
117
120
  async function requireMfa(organization: Organization | null, email: string, password: string): Promise<{ token: string; methods: string[]; webauthnAuthenticationOptions: unknown }> {
@@ -668,8 +671,7 @@ describe('MFA', () => {
668
671
  const err = await captureError(testServer.test(tokenEndpoint, passwordLogin(organization, user.email, password)));
669
672
  expect(err.code).toBe('require_email_confirmation');
670
673
  expect(send).toHaveBeenCalledOnce();
671
- }
672
- finally {
674
+ } finally {
673
675
  send.mockRestore();
674
676
  }
675
677
  });
@@ -690,7 +692,7 @@ describe('MFA', () => {
690
692
  // The identity provider authenticated the user, so there is nothing to confirm.
691
693
  const { organization, user } = await inactiveAdmin();
692
694
 
693
- const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'sso' });
695
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: SessionLoginMethod.SSO });
694
696
  expect(requirement.type).toBe('setup');
695
697
  });
696
698
  });
@@ -1414,14 +1416,14 @@ describe('MFA', () => {
1414
1416
  const { organization, user } = await adminOfOrgRequiringTwoFactor({ withPassword: false });
1415
1417
  await addConfirmedTOTP(user);
1416
1418
 
1417
- const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'sso' });
1419
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: SessionLoginMethod.SSO });
1418
1420
  expect(requirement.type).toBe('challenge');
1419
1421
  });
1420
1422
 
1421
1423
  test('an SSO-only account is not forced to enroll: the provider is the second factor', async () => {
1422
1424
  const { organization, user } = await adminOfOrgRequiringTwoFactor({ withPassword: false });
1423
1425
 
1424
- const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'sso' });
1426
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: SessionLoginMethod.SSO });
1425
1427
  expect(requirement.type).toBe('none');
1426
1428
  });
1427
1429
 
@@ -1430,16 +1432,41 @@ describe('MFA', () => {
1430
1432
  // requirement is not satisfied by signing in through SSO.
1431
1433
  const { organization, user } = await adminOfOrgRequiringTwoFactor({ withPassword: true });
1432
1434
 
1433
- const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'sso' });
1435
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: SessionLoginMethod.SSO });
1434
1436
  expect(requirement.type).toBe('setup');
1435
1437
  });
1436
1438
 
1437
1439
  test('a password login is always forced to enroll', async () => {
1438
1440
  const { organization, user } = await adminOfOrgRequiringTwoFactor({ withPassword: true });
1439
1441
 
1440
- const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: 'password' });
1442
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: SessionLoginMethod.Password });
1441
1443
  expect(requirement.type).toBe('setup');
1442
1444
  });
1445
+
1446
+ test('the session of an SSO login that passed a second factor is an SSO session', async () => {
1447
+ // The session is only created after the second factor, so the challenge has to
1448
+ // remember how the user got there: it decides how long the session may live.
1449
+ const { organization, user } = await adminOfOrgRequiringTwoFactor({ withPassword: false });
1450
+ const { secret } = await addConfirmedTOTP(user);
1451
+
1452
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, organization, { loginMethod: SessionLoginMethod.SSO });
1453
+ if (requirement.type !== 'challenge') {
1454
+ throw new Error('Expected a challenge');
1455
+ }
1456
+
1457
+ const response = await testServer.test(tokenEndpoint, mfaGrant(organization, {
1458
+ mfa_token: requirement.challenge.token,
1459
+ method: 'TOTP',
1460
+ code: authenticator.generate(secret),
1461
+ }));
1462
+ if (!(response.body instanceof TokenStruct)) {
1463
+ throw new Error('Expected TokenStruct');
1464
+ }
1465
+
1466
+ const token = await Token.getByAccessToken(response.body.accessToken);
1467
+ expect((await UserSession.getByID(token!.sessionId))!.loginMethod).toBe(SessionLoginMethod.SSO);
1468
+ expect(token!.refreshTokenValidUntil.getTime()).toBeLessThanOrEqual(Date.now() + 3 * 60 * 60 * 1000);
1469
+ });
1443
1470
  });
1444
1471
 
1445
1472
  // -----------------------------------------------------------------------