@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
@@ -2,8 +2,8 @@ import type { DecodedRequest } from '@simonbackx/simple-endpoints';
2
2
  import { Response } from '@simonbackx/simple-endpoints';
3
3
  import { isSimpleError, isSimpleErrors, SimpleError } from '@simonbackx/simple-errors';
4
4
  import { Organization, Platform, Token, User, Webshop } from '@stamhoofd/models';
5
- import type { LoginMethod, StartOpenIDFlowStruct } from '@stamhoofd/structures';
6
- import { getAppHost, LoginProviderType, OpenIDClientConfiguration, Token as TokenStruct } from '@stamhoofd/structures';
5
+ import type { LoginMethod, SessionMetaData, StartOpenIDFlowStruct } from '@stamhoofd/structures';
6
+ import { getAppHost, LoginProviderType, OpenIDClientConfiguration, SessionClientType, SessionLoginMethod, Token as TokenStruct } from '@stamhoofd/structures';
7
7
  import crypto from 'crypto';
8
8
  import { generators, Issuer } from 'openid-client';
9
9
  import { Context } from '../helpers/Context.js';
@@ -11,6 +11,7 @@ import { Context } from '../helpers/Context.js';
11
11
  import type { ObjectWithHeaders } from '../helpers/CookieHelper.js';
12
12
  import { CookieHelper } from '../helpers/CookieHelper.js';
13
13
  import { TwoFactorHelper } from '../helpers/TwoFactorHelper.js';
14
+ import { SessionService } from './SessionService.js';
14
15
 
15
16
  async function randomBytes(size: number): Promise<Buffer> {
16
17
  return new Promise((resolve, reject) => {
@@ -33,6 +34,8 @@ type SSOSessionContext = {
33
34
  spaState: string;
34
35
  providerType: LoginProviderType;
35
36
  organizationId?: string | null;
37
+ clientType: SessionClientType;
38
+ sessionMetaData?: SessionMetaData;
36
39
 
37
40
  /**
38
41
  * Link this method to this existing user and don't create a new token
@@ -419,7 +422,13 @@ export class SSOService {
419
422
  }
420
423
  }
421
424
 
422
- return await this.startAuthCodeFlow(redirectUri, data.spaState, data.prompt, user, reauthenticateAccessToken);
425
+ let clientType = SessionClientType.Browser;
426
+ if (data.clientPlatform === 'ios') {
427
+ clientType = SessionClientType.iOS;
428
+ } else if (data.clientPlatform === 'android') {
429
+ clientType = SessionClientType.Android;
430
+ }
431
+ return await this.startAuthCodeFlow(redirectUri, data.spaState, data.prompt, user, reauthenticateAccessToken, clientType, SessionService.parseMetaData(data.sessionMetaData));
423
432
  }
424
433
 
425
434
  validateRedirectUri(uri: string) {
@@ -454,7 +463,7 @@ export class SSOService {
454
463
  }
455
464
  }
456
465
 
457
- async startAuthCodeFlow(redirectUri: string, spaState: string, prompt: string | null = null, user?: User, reauthenticateAccessToken: string | null = null): Promise<Response<undefined>> {
466
+ async startAuthCodeFlow(redirectUri: string, spaState: string, prompt: string | null = null, user?: User, reauthenticateAccessToken: string | null = null, clientType = SessionClientType.Browser, sessionMetaData = SessionService.parseMetaData(null)): Promise<Response<undefined>> {
458
467
  const code_verifier = generators.codeVerifier();
459
468
  const state = generators.state(); // this is the internal state backend <-> SSO provider
460
469
  const nonce = generators.nonce();
@@ -470,6 +479,8 @@ export class SSOService {
470
479
  spaState, // this is the state frontend <-> backend (not backend <-> SSO provider)
471
480
  providerType: this.provider,
472
481
  organizationId: this.organization?.id ?? null,
482
+ clientType,
483
+ sessionMetaData,
473
484
  userId: user?.id ?? null,
474
485
  reauthenticateAccessToken,
475
486
  };
@@ -696,7 +707,7 @@ export class SSOServiceWithSession {
696
707
  //
697
708
  // This is a redirect, not a request/response pair, so the client gets the
698
709
  // token of the pending step in the URL and picks the flow up from there.
699
- const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, this.service.organization, { loginMethod: 'sso' });
710
+ const requirement = await TwoFactorHelper.getSecondFactorRequirement(user, this.service.organization, { loginMethod: SessionLoginMethod.SSO });
700
711
 
701
712
  if (requirement.type === 'challenge') {
702
713
  redirectUri.searchParams.set('oid_mfa', requirement.challenge.token);
@@ -708,7 +719,7 @@ export class SSOServiceWithSession {
708
719
  redirectUri.searchParams.set('oid_mfa_passkeys', user.canUsePasskeys() ? '1' : '0');
709
720
  redirectUri.searchParams.set('s', session.spaState);
710
721
  } else if (requirement.type === 'none') {
711
- const token = await Token.createExpiredToken(user);
722
+ const token = await SessionService.createSSOHandoff(user, { clientType: session.clientType, metaData: session.sessionMetaData ?? SessionService.parseMetaData(null) });
712
723
 
713
724
  if (!token) {
714
725
  throw new SimpleError({
@@ -0,0 +1,319 @@
1
+ import { Request } from '@simonbackx/simple-endpoints';
2
+ import type { Organization, User } from '@stamhoofd/models';
3
+ import { OrganizationFactory, Token, UserFactory, UserSession } from '@stamhoofd/models';
4
+ import type { SessionType } from '@stamhoofd/models/constants/sessions.js';
5
+ import { ACCESS_TOKEN_DURATION, SESSION_DURATIONS, SSO_HANDOFF_TOKEN_DURATION } from '@stamhoofd/models/constants/sessions.js';
6
+ import { PermissionLevel, Permissions, SessionClientType, SessionDeviceType, SessionLoginMethod, SessionOS } from '@stamhoofd/structures';
7
+ import { TestUtils } from '@stamhoofd/test-utils';
8
+
9
+ import { ContextInstance } from '../helpers/Context.js';
10
+ import { SessionService } from './SessionService.js';
11
+
12
+ const MINUTE = 60 * 1000;
13
+ const HOUR = 60 * MINUTE;
14
+ const DAY = 24 * HOUR;
15
+
16
+ function humanDuration(duration: number): string {
17
+ return duration % DAY === 0 ? `${duration / DAY} days` : `${duration / HOUR} hours`;
18
+ }
19
+
20
+ async function onPlatform<T>(platform: string, handler: () => Promise<T>): Promise<T> {
21
+ const request = new Request({ method: 'POST', url: '/oauth/token', host: 'api.example.com', headers: { 'x-platform': platform } });
22
+ return await ContextInstance.start(request, handler);
23
+ }
24
+
25
+ function expectValidFor(date: Date, duration: number) {
26
+ // A second of leeway: the token is created a moment before this runs.
27
+ expect(date.getTime()).toBeGreaterThan(Date.now() + duration - 5000);
28
+ expect(date.getTime()).toBeLessThanOrEqual(Date.now() + duration);
29
+ }
30
+
31
+ describe('SessionService', () => {
32
+ let organization: Organization;
33
+
34
+ beforeEach(async () => {
35
+ TestUtils.setEnvironment('userMode', 'platform');
36
+ organization = await new OrganizationFactory({}).create();
37
+ });
38
+
39
+ async function createMember(): Promise<User> {
40
+ return await new UserFactory({ organization }).create();
41
+ }
42
+
43
+ async function createAdmin(): Promise<User> {
44
+ return await new UserFactory({
45
+ organization,
46
+ permissions: Permissions.create({ level: PermissionLevel.Full }),
47
+ }).create();
48
+ }
49
+
50
+ async function createPlatformAdmin(): Promise<User> {
51
+ return await new UserFactory({
52
+ globalPermissions: Permissions.create({ level: PermissionLevel.Full }),
53
+ }).create();
54
+ }
55
+
56
+ async function getSession(token: Token): Promise<UserSession> {
57
+ const session = await UserSession.getByID(token.sessionId);
58
+ if (!session) throw new Error('Expected user session');
59
+ return session;
60
+ }
61
+
62
+ const policy: { name: string; platform: string; clientType: SessionClientType; sessionType: SessionType; loginMethod: SessionLoginMethod; createUser: () => Promise<User> }[] = [
63
+ { name: 'a member in a browser', platform: 'web', clientType: SessionClientType.Browser, sessionType: 'user', loginMethod: SessionLoginMethod.Password, createUser: () => createMember() },
64
+ { name: 'a member in the native app', platform: 'ios', clientType: SessionClientType.iOS, sessionType: 'user', loginMethod: SessionLoginMethod.Password, createUser: () => createMember() },
65
+ { name: 'an administrator in a browser', platform: 'web', clientType: SessionClientType.Browser, sessionType: 'admin', loginMethod: SessionLoginMethod.Password, createUser: () => createAdmin() },
66
+ { name: 'an administrator in the native app', platform: 'android', clientType: SessionClientType.Android, sessionType: 'admin', loginMethod: SessionLoginMethod.Password, createUser: () => createAdmin() },
67
+ { name: 'a platform administrator in a browser', platform: 'web', clientType: SessionClientType.Browser, sessionType: 'platformAdmin', loginMethod: SessionLoginMethod.Password, createUser: () => createPlatformAdmin() },
68
+ { name: 'a platform administrator in the native app', platform: 'ios', clientType: SessionClientType.iOS, sessionType: 'platformAdmin', loginMethod: SessionLoginMethod.Password, createUser: () => createPlatformAdmin() },
69
+ { name: 'an SSO login in a browser', platform: 'web', clientType: SessionClientType.Browser, sessionType: 'sso', loginMethod: SessionLoginMethod.SSO, createUser: () => createMember() },
70
+ { name: 'an SSO login in the native app', platform: 'ios', clientType: SessionClientType.iOS, sessionType: 'sso', loginMethod: SessionLoginMethod.SSO, createUser: () => createAdmin() },
71
+ ];
72
+
73
+ test('creates and persists a token with its session', async () => {
74
+ const user = await createMember();
75
+ const token = await SessionService.createSession(user);
76
+
77
+ expect(token).toBeInstanceOf(Token);
78
+ expect(token.user).toBe(user);
79
+ expect(token.userId).toBe(user.id);
80
+ expect(token.accessToken).toHaveLength(256);
81
+ expect(token.refreshToken).toHaveLength(256);
82
+
83
+ const session = await getSession(token);
84
+ expect(session).toMatchObject({
85
+ userId: user.id,
86
+ lastUsedTokenId: token.id,
87
+ lastActiveTokenId: token.id,
88
+ clientType: SessionClientType.Browser,
89
+ loginMethod: SessionLoginMethod.Password,
90
+ });
91
+ expect(await Token.getByAccessToken(token.accessToken)).toMatchObject({
92
+ id: token.id,
93
+ sessionId: session.id,
94
+ refreshToken: token.refreshToken,
95
+ });
96
+ });
97
+
98
+ describe('session length', () => {
99
+ for (const { name, platform, clientType, sessionType, loginMethod, createUser } of policy) {
100
+ const { session, refreshToken } = SESSION_DURATIONS[sessionType][clientType];
101
+
102
+ test(`${name} may go unused for ${humanDuration(refreshToken)}`, async () => {
103
+ const user = await createUser();
104
+ const token = await onPlatform(platform, () => SessionService.createSession(user, { loginMethod }));
105
+
106
+ expectValidFor(token.refreshTokenValidUntil, refreshToken);
107
+ });
108
+
109
+ if (session === null) {
110
+ test(`${name} has no maximum length`, async () => {
111
+ const user = await createUser();
112
+ const token = await onPlatform(platform, () => SessionService.createSession(user, { loginMethod }));
113
+
114
+ const userSession = await getSession(token);
115
+ userSession.startedAt = new Date(Date.now() - 10 * refreshToken);
116
+ await userSession.save();
117
+
118
+ const rotated = await SessionService.rotateSession(token);
119
+ expectValidFor(rotated.refreshTokenValidUntil, refreshToken);
120
+ });
121
+ } else {
122
+ test(`${name} is signed out after ${humanDuration(session)}`, async () => {
123
+ const user = await createUser();
124
+ const token = await onPlatform(platform, () => SessionService.createSession(user, { loginMethod }));
125
+
126
+ const userSession = await getSession(token);
127
+ userSession.startedAt = new Date(Date.now() - session + HOUR);
128
+ await userSession.save();
129
+
130
+ const rotated = await SessionService.rotateSession(token);
131
+ expectValidFor(rotated.refreshTokenValidUntil, HOUR);
132
+ });
133
+ }
134
+ }
135
+
136
+ test('the access token uses the configured duration', async () => {
137
+ const admin = await createAdmin();
138
+ const token = await SessionService.createSession(admin, { loginMethod: SessionLoginMethod.Password });
139
+
140
+ expectValidFor(token.accessTokenValidUntil, ACCESS_TOKEN_DURATION);
141
+ });
142
+
143
+ test('the access token never outlives the session', async () => {
144
+ const admin = await createPlatformAdmin();
145
+ const token = await SessionService.createSession(admin, { loginMethod: SessionLoginMethod.SSO });
146
+
147
+ const sessionDuration = SESSION_DURATIONS.sso[SessionClientType.Browser].session;
148
+ if (sessionDuration === null) {
149
+ throw new Error('Expected browser SSO sessions to have a maximum length');
150
+ }
151
+ const userSession = await getSession(token);
152
+ userSession.startedAt = new Date(Date.now() - sessionDuration + MINUTE);
153
+ await userSession.save();
154
+
155
+ const rotated = await SessionService.rotateSession(token);
156
+ expectValidFor(rotated.accessTokenValidUntil, MINUTE);
157
+ });
158
+ });
159
+
160
+ describe('inactivity', () => {
161
+ test('renewing the access token moves the inactivity limit', async () => {
162
+ const admin = await createAdmin();
163
+ const token = await SessionService.createSession(admin, { loginMethod: SessionLoginMethod.Password });
164
+
165
+ token.refreshTokenValidUntil = new Date(Date.now() + 60 * 1000);
166
+ await token.save();
167
+
168
+ const rotated = await SessionService.rotateSession(token);
169
+ expectValidFor(rotated.refreshTokenValidUntil, SESSION_DURATIONS.admin[SessionClientType.Browser].refreshToken);
170
+ });
171
+ });
172
+
173
+ describe('rotation', () => {
174
+ test('the new token continues the same session', async () => {
175
+ const admin = await createAdmin();
176
+ const token = await onPlatform('android', () => SessionService.createSession(admin, { loginMethod: SessionLoginMethod.SSO }));
177
+
178
+ const rotated = await SessionService.rotateSession(token);
179
+ const session = await getSession(rotated);
180
+ expect(rotated.sessionId).toBe(token.sessionId);
181
+ expect(session.loginMethod).toBe(SessionLoginMethod.SSO);
182
+ expect(session.clientType).toBe(SessionClientType.Android);
183
+ });
184
+
185
+ test('the platform of the login is kept, not the one of the rotation', async () => {
186
+ const admin = await createAdmin();
187
+ const token = await SessionService.createSession(admin, { loginMethod: SessionLoginMethod.Password });
188
+
189
+ const rotated = await onPlatform('ios', () => SessionService.rotateSession(token));
190
+
191
+ expect((await getSession(rotated)).clientType).toBe(SessionClientType.Browser);
192
+ expectValidFor(rotated.refreshTokenValidUntil, SESSION_DURATIONS.admin[SessionClientType.Browser].refreshToken);
193
+ });
194
+
195
+ test('a replacement only retires the previous token after it becomes active', async () => {
196
+ const admin = await createAdmin();
197
+ const original = await SessionService.createSession(admin, { loginMethod: SessionLoginMethod.Password });
198
+ const replacement = await SessionService.rotateSession(original);
199
+ let session = await getSession(original);
200
+
201
+ expect(session.lastActiveTokenId).toBe(original.id);
202
+ expect(session.lastUsedTokenId).toBe(replacement.id);
203
+ expect(await Token.getByAccessToken(original.accessToken)).toBeDefined();
204
+
205
+ expect(await SessionService.activateToken(replacement)).toBe(true);
206
+ session = await getSession(replacement);
207
+ expect(session.lastActiveTokenId).toBe(replacement.id);
208
+ expect(await Token.getByAccessToken(original.accessToken)).toBeUndefined();
209
+ expect(await SessionService.activateToken(original)).toBe(false);
210
+ });
211
+
212
+ test('the active token can retry a renewal whose response was lost', async () => {
213
+ const admin = await createAdmin();
214
+ const original = await SessionService.createSession(admin, { loginMethod: SessionLoginMethod.Password });
215
+ const lostReplacement = await SessionService.rotateSession(original);
216
+ const retryReplacement = await SessionService.rotateSession(original);
217
+
218
+ expect(retryReplacement.sessionId).toBe(original.sessionId);
219
+ expect(await Token.getByAccessToken(lostReplacement.accessToken)).toBeUndefined();
220
+ expect(await Token.getByAccessToken(original.accessToken)).toBeDefined();
221
+ });
222
+
223
+ test('renewal updates versions but keeps stable device metadata', async () => {
224
+ const admin = await createAdmin();
225
+ const initial = encodeURIComponent(JSON.stringify({
226
+ deviceType: SessionDeviceType.Phone,
227
+ deviceName: 'iPhone 15 Pro',
228
+ osName: SessionOS.iOS,
229
+ osVersion: '18.1',
230
+ appVersion: '2.0',
231
+ nativeAppVersion: '1.0',
232
+ browserName: null,
233
+ }));
234
+ const token = await ContextInstance.start(new Request({ method: 'POST', url: '/oauth/token', host: 'api.example.com', headers: { 'x-platform': 'ios', 'x-session-metadata': initial } }), () => SessionService.createSession(admin, { loginMethod: SessionLoginMethod.Password }));
235
+
236
+ const changed = encodeURIComponent(JSON.stringify({
237
+ deviceType: SessionDeviceType.Desktop,
238
+ deviceName: 'Changed',
239
+ osName: SessionOS.Windows,
240
+ osVersion: '18.2',
241
+ appVersion: '2.1',
242
+ nativeAppVersion: '1.1',
243
+ browserName: 'Other',
244
+ }));
245
+ await ContextInstance.start(new Request({ method: 'POST', url: '/oauth/token', host: 'api.example.com', headers: { 'x-platform': 'web', 'x-session-metadata': changed } }), () => SessionService.rotateSession(token));
246
+
247
+ const session = await getSession(token);
248
+ expect(session).toMatchObject({
249
+ clientType: SessionClientType.iOS,
250
+ deviceType: SessionDeviceType.Phone,
251
+ deviceName: 'iPhone 15 Pro',
252
+ osName: SessionOS.iOS,
253
+ osVersion: '18.2',
254
+ appVersion: '2.1',
255
+ nativeAppVersion: '1.1',
256
+ browserName: null,
257
+ });
258
+ });
259
+
260
+ test('a member that becomes a platform administrator is limited from that moment on', async () => {
261
+ const user = await createMember();
262
+ const token = await SessionService.createSession(user, { loginMethod: SessionLoginMethod.Password });
263
+ expectValidFor(token.refreshTokenValidUntil, SESSION_DURATIONS.user[SessionClientType.Browser].refreshToken);
264
+
265
+ const platformAdmin = await createPlatformAdmin();
266
+ token.user.permissions = platformAdmin.permissions;
267
+ await token.user.save();
268
+
269
+ const rotated = await SessionService.rotateSession(token);
270
+ expectValidFor(rotated.refreshTokenValidUntil, SESSION_DURATIONS.platformAdmin[SessionClientType.Browser].refreshToken);
271
+ });
272
+ });
273
+
274
+ describe('expired sessions', () => {
275
+ test('an expired session cannot be renewed', async () => {
276
+ const admin = await createAdmin();
277
+ const token = await SessionService.createSession(admin, { loginMethod: SessionLoginMethod.Password });
278
+
279
+ token.refreshTokenValidUntil = new Date(Date.now() - 1000);
280
+ await token.save();
281
+
282
+ expect(await SessionService.getByRefreshToken(token.refreshToken)).toBeUndefined();
283
+ });
284
+
285
+ test('an expired refresh token ends every session of the user', async () => {
286
+ const admin = await createAdmin();
287
+ const expired = await SessionService.createSession(admin, { loginMethod: SessionLoginMethod.Password });
288
+ const other = await SessionService.createSession(admin, { loginMethod: SessionLoginMethod.Password });
289
+
290
+ expired.refreshTokenValidUntil = new Date(Date.now() - 1000);
291
+ await expired.save();
292
+
293
+ // Using an expired refresh token can mean the token was stolen, so the account
294
+ // is signed out everywhere instead of only on this session.
295
+ await SessionService.getByRefreshToken(expired.refreshToken);
296
+
297
+ expect(await Token.getByAccessToken(expired.accessToken)).toBeUndefined();
298
+ expect(await Token.getByAccessToken(other.accessToken)).toBeUndefined();
299
+ expect(await UserSession.getByID(expired.sessionId)).toBeUndefined();
300
+ expect(await UserSession.getByID(other.sessionId)).toBeUndefined();
301
+ });
302
+ });
303
+
304
+ describe('SSO sessions', () => {
305
+ test('the session still has to be renewed before it can be used', async () => {
306
+ const admin = await createAdmin();
307
+ const token = await SessionService.createSSOHandoff(admin);
308
+
309
+ expect(token.isAccessTokenExpired()).toBe(true);
310
+ expectValidFor(token.refreshTokenValidUntil, SSO_HANDOFF_TOKEN_DURATION);
311
+
312
+ const rotated = await SessionService.rotateSession(token);
313
+ expect((await getSession(rotated)).loginMethod).toBe(SessionLoginMethod.SSO);
314
+ expect(rotated.isAccessTokenExpired()).toBe(false);
315
+ expectValidFor(rotated.refreshTokenValidUntil, SESSION_DURATIONS.sso[SessionClientType.Browser].refreshToken);
316
+ expect(await SessionService.getByRefreshToken(token.refreshToken)).toBeUndefined();
317
+ });
318
+ });
319
+ });