@stamhoofd/backend 2.144.0 → 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.
- package/package.json +19 -19
- package/src/crons/mollie-chargebacks.ts +31 -29
- package/src/crons/mollie-refunds.ts +61 -59
- package/src/endpoints/admin/organizations/PatchOrganizationsEndpoint.ts +16 -5
- package/src/endpoints/auth/CreateTokenEndpoint.test.ts +210 -4
- package/src/endpoints/auth/CreateTokenEndpoint.ts +9 -20
- package/src/endpoints/auth/DeleteTokenEndpoint.test.ts +29 -0
- package/src/endpoints/auth/DeleteTokenEndpoint.ts +2 -1
- package/src/endpoints/auth/GetUserEndpoint.test.ts +32 -11
- package/src/endpoints/auth/MFA.security.test.ts +9 -7
- package/src/endpoints/auth/MFA.test.ts +42 -15
- package/src/endpoints/auth/PatchUserEndpoint.ts +3 -2
- package/src/endpoints/auth/VerifyEmailEndpoint.ts +5 -4
- package/src/endpoints/global/email/GetAdminEmailsEndpoint.test.ts +4 -3
- package/src/endpoints/global/email/GetUserEmailsEndpoint.test.ts +6 -5
- package/src/endpoints/global/email/PatchEmailEndpoint.test.ts +5 -4
- package/src/endpoints/global/email-recipients/GetEmailRecipientsEndpoint.test.ts +5 -4
- package/src/endpoints/global/events/PatchEventNotificationsEndpoint.test.ts +2 -1
- package/src/endpoints/global/events/PatchEventsEndpoint.test.ts +2 -1
- package/src/endpoints/global/files/UploadFile.test.ts +2 -1
- package/src/endpoints/global/files/UploadImage.test.ts +2 -1
- package/src/endpoints/global/files/upload-security.test.ts +3 -2
- package/src/endpoints/global/groups/GetGroupsEndpoint.test.ts +4 -3
- package/src/endpoints/global/members/GetMembersEndpoint.test.ts +26 -25
- package/src/endpoints/global/members/PatchOrganizationMembersEndpoint.test.ts +57 -56
- package/src/endpoints/global/members/SendMemberSecurityCodeEndpoint.test.ts +4 -3
- package/src/endpoints/global/platform/GetPlatformEndpoint.test.ts +6 -4
- package/src/endpoints/global/platform/PatchPlatformEnpoint.test.ts +6 -6
- package/src/endpoints/global/platform/SignOutPlatformAdminsEndpoint.test.ts +9 -8
- package/src/endpoints/global/registration/GetRegistrationsEndpoint.test.ts +14 -13
- package/src/endpoints/global/registration/PatchUserMembersEndpoint.test.ts +24 -23
- package/src/endpoints/global/registration/RegisterMembersEndpoint.test.ts +4 -3
- package/src/endpoints/global/registration-invitations/GetRegistrationInvitationsEndpoint.test.ts +2 -1
- package/src/endpoints/global/registration-invitations/PatchRegistrationInvitationsEndpoint.test.ts +2 -1
- package/src/endpoints/global/webshops/GetWebshopsEndpoint.test.ts +20 -19
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemBreakdownEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/balance-items/GetBalanceItemsEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/billing/ApplyRegisterCodeEndpoint.test.ts +3 -2
- package/src/endpoints/organization/dashboard/documents/PatchDocumentTemplatesEndpoint.test.ts +4 -3
- package/src/endpoints/organization/dashboard/email-templates/GetEmailTemplatesEndpoint.test.ts +5 -4
- package/src/endpoints/organization/dashboard/email-templates/PatchEmailTemplatesEndpoint.test.ts +5 -4
- package/src/endpoints/organization/dashboard/invoices/GetInvoicesEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/invoices/PatchInvoicesEndpoint.ts +0 -1
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.test.ts +23 -22
- package/src/endpoints/organization/dashboard/organization/PatchOrganizationEndpoint.ts +4 -4
- package/src/endpoints/organization/dashboard/payments/GetPaymentBreakdownEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/payments/GetPaymentsEndpoint.test.ts +4 -3
- package/src/endpoints/organization/dashboard/payments/PatchPaymentsEndpoint.ts +2 -2
- package/src/endpoints/organization/dashboard/receivable-balances/GetReceivableBalanceEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/registration-periods/GetOrganizationRegistrationPeriodsEndpoint.test.ts +5 -4
- package/src/endpoints/organization/dashboard/registration-periods/MoveRegistrationPeriods.test.ts +7 -6
- package/src/endpoints/organization/dashboard/registration-periods/PatchOrganizationRegistrationPeriodsEndpoint.test.ts +9 -8
- package/src/endpoints/organization/dashboard/settlements/SettlementsExportEndpoint.test.ts +3 -2
- package/src/endpoints/organization/dashboard/settlements/SettlementsSyncEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.test.ts +5 -4
- package/src/endpoints/organization/dashboard/users/CreateApiUserEndpoint.ts +2 -1
- package/src/endpoints/organization/dashboard/users/GetOrganizationAdminsEndpoint.test.ts +3 -2
- package/src/endpoints/organization/dashboard/users/PatchApiUserEndpoint.test.ts +6 -5
- package/src/endpoints/organization/dashboard/users/SignOutOrganizationAdminsEndpoint.test.ts +16 -15
- package/src/endpoints/organization/dashboard/webshops/GetWebshopOrdersEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/webshops/PatchWebshopOrdersEndpoint.test.ts +2 -1
- package/src/endpoints/organization/dashboard/webshops/WebshopReservedPathSegments.test.ts +2 -1
- package/src/endpoints/organization/shared/auth/GetOrganizationEndpoint.test.ts +4 -3
- package/src/endpoints/organization/webshops/GetWebshopEndpoint.test.ts +6 -5
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.test.ts +94 -8
- package/src/endpoints/organization/webshops/PlaceOrderEndpoint.ts +18 -18
- package/src/helpers/Context.ts +13 -6
- package/src/helpers/ContextStorage.ts +5 -0
- package/src/helpers/StripeSettlementSync.ts +10 -7
- package/src/helpers/TwoFactorHelper.ts +21 -22
- package/src/services/AdminSessionService.ts +5 -3
- package/src/services/InvoiceService.ts +2 -2
- package/src/services/MollieService.ts +168 -126
- package/src/services/PaymentService.ts +2 -2
- package/src/services/SSOService.test.ts +25 -1
- package/src/services/SSOService.ts +17 -6
- package/src/services/SessionService.test.ts +319 -0
- package/src/services/SessionService.ts +357 -0
- package/src/services/WebshopCrowdfundingService.test.ts +4 -3
- package/src/sql-filters/registrations.ts +59 -1
- package/tests/actions/patchUserMember.ts +2 -1
- package/tests/e2e/api-rate-limits.test.ts +2 -1
- package/tests/e2e/bundle-discounts.test.ts +4 -3
- package/tests/e2e/charge-members.test.ts +6 -5
- package/tests/e2e/documents.test.ts +4 -3
- package/tests/e2e/private-files.test.ts +9 -8
- package/tests/e2e/register.test.ts +4 -3
- package/tests/e2e/stock.test.ts +4 -3
- package/tests/e2e/tickets.test.ts +4 -3
- package/tests/helpers/ExportSlice.ts +2 -1
- package/tests/helpers/MollieMocker.ts +85 -23
- package/tests/init/initAdmin.ts +2 -1
- package/tests/init/initPlatformAdmin.ts +2 -1
- package/src/helpers/ViesHelper.test.ts +0 -401
- package/src/helpers/ViesHelper.ts +0 -222
- package/src/services/PeppolDirectoryService.test.ts +0 -139
- 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
|
-
|
|
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:
|
|
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
|
|
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
|
+
});
|