snapshot of https://github.com/twentyhq/twenty.git (main, 2026-09-17; blobs > 131072B omitted)
Some checks failed
App Prod-Parity E2E Dispatch / dispatch (push) Failing after 10s
CD Storybook UI / build (push) Failing after 3m25s
CD Storybook UI / dispatch (push) Has been skipped
CD deploy main / website-changed-files (push) Successful in 18m8s
CI Agent Skills / changed-files-check (push) Successful in 18m9s
CI Create App E2E minimal / changed-files-check (push) Successful in 24s
CI Create App / changed-files-check (push) Successful in 26s
CI Docs / changed-files-check (push) Successful in 26s
CI Emails / changed-files-check (push) Successful in 28s
CI Example App Hello World / changed-files-check (push) Successful in 23s
CI Front / changed-files-check (push) Has been skipped
CI Example App Postcard / changed-files-check (push) Successful in 27s
CI UI / changed-files-check (push) Has been skipped
CI Utils Test / changed-files-check (push) Successful in 45s
CI Twenty Apps / discover (push) Successful in 1m3s
CD deploy main / deploy-main (push) Failing after 3s
CI Agent Skills / agent-skills-validate (push) Has been skipped
CI Create App / create-app-test (lint) (push) Has been skipped
CI Emails / emails-test (push) Has been skipped
CI Front / front-task (lingui:compile) (push) Has been skipped
CI Front / front-task (lint) (push) Has been skipped
CI Example App Postcard / example-app-postcard (push) Has been skipped
CI UI / ui-task (lint) (push) Has been skipped
CI UI / ui-task (pack-size) (push) Has been skipped
CI UI / ui-task (size) (push) Has been skipped
CI UI / ui-task (test) (push) Has been skipped
CI UI / ui-task (typecheck) (push) Has been skipped
CI Utils Test / utils-test (push) Has been skipped
CI Twenty Apps / ${{ matrix.app.name }} (push) Has been skipped
CI Emails / ci-emails-status-check (push) Successful in 2s
CI Example App Hello World / ci-example-app-hello-world-status-check (push) Successful in 2s
CI Example App Postcard / ci-example-app-postcard-status-check (push) Successful in 3s
CI UI / ui-sb-test (push) Has been skipped
CI Utils Test / ci-utils-test-status-check (push) Successful in 2s
CI Twenty Apps / ci-twenty-apps-status-check (push) Successful in 2s
CI UI / ci-ui-status-check (push) Successful in 2s
CI Desktop / desktop (push) Failing after 1m8s
Push docs to Crowdin / Push documentation to Crowdin (push) Failing after 55s
Push translations to Crowdin / Extract and upload translations (push) Failing after 5s
Push website translations to Crowdin / Extract and upload website translations (push) Failing after 5s
CI Create App / create-app-test (test) (push) Has been skipped
CI Create App / create-app-test (typecheck) (push) Has been skipped
CI Docs / docs-lint (push) Has been skipped
CI Example App Hello World / example-app-hello-world (push) Has been skipped
CI Front / front-task (lingui:extract) (push) Has been skipped
CI Front / front-task (test) (push) Has been skipped
CI Front / front-task (typecheck) (push) Has been skipped
CI UI / ui-sb-build (push) Failing after 1m32s
CI Twenty Apps / ${{ matrix.app.name }} (${{ matrix.server-source }}) (push) Has been skipped
CI Agent Skills / agent-skills-publish (push) Has been skipped
CI Create App / ci-create-app-status-check (push) Successful in 2s
CI Agent Skills / agent-skills-remote-source (push) Has been skipped
CI Create App E2E minimal / create-app-e2e-minimal (push) Has been cancelled
CI E2E Main / e2e-test (push) Has been cancelled
CI Front / front-sb-build (push) Has been cancelled
CI Front / front-build (push) Has been cancelled
CI Create App E2E minimal / ci-create-app-e2e-minimal-status-check (push) Has been cancelled
CI E2E Main / ci-e2e-main-status-check (push) Has been cancelled
CI E2E Main / notify-main-ci-failure (push) Has been cancelled
CI E2E Main / qa-scout-comment (push) Has been cancelled
CI Front / front-sb-test (push) Has been cancelled
CI Front / front-sb-screenshots (push) Has been cancelled
CI Front / ci-front-status-check (push) Has been cancelled
DPA Sub-Processor Sync / sync-subprocessors (push) Failing after 3s
AI Catalog Sync / sync-catalog (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-17 23:28:13 +05:00
commit e7da97194a
32688 changed files with 4208681 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
import request from 'supertest';
import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant';
const SERVER_URL = `http://localhost:${APP_PORT}`;
const buildAppleOrigin = (): string => {
const origin = new URL(SERVER_URL);
origin.hostname =
process.env.IS_MULTIWORKSPACE_ENABLED === 'true'
? `apple.${origin.hostname}`
: origin.hostname;
return origin.origin;
};
// The app listens on localhost while the seeded workspace resolves from the
// apple subdomain. Without a matching Host the server sees a cross-origin
// request and refuses to issue the cookie, which is the behaviour under test
// everywhere except here.
const asWorkspaceRequest = (
agent: request.Test,
originOverride?: string,
): request.Test => {
const host = new URL(origin).host;
return originOverride === undefined
? agent.set('Host', host).set('Origin', origin)
: agent.set('Host', host).set('Origin', originOverride);
};
const CURRENT_USER_QUERY = `
query CurrentUser {
currentUser {
id
email
}
}
`;
const SIGN_OUT_MUTATION = `
mutation SignOut {
signOut
}
`;
// A workspace object rather than currentUser: workspace types only exist in the
// schema once a workspace is bound, so a request that is allowed to continue
// unauthenticated fails with "Cannot query field" instead of an auth error.
const FIND_COMPANIES_QUERY = `
query FindCompanies {
companies(first: 1) {
edges {
node {
id
}
}
}
}
`;
const AUTH_ERROR_CODES = ['UNAUTHENTICATED', 'FORBIDDEN'];
const extractSessionCookie = (
setCookieHeader: string | string[] | undefined,
): string | undefined => {
const setCookies =
typeof setCookieHeader === 'string'
? [setCookieHeader]
: (setCookieHeader ?? []);
return setCookies
.find((cookie) => cookie.startsWith(`${USER_SESSION_COOKIE_NAME}=`))
?.split(';')[0];
};
const origin = buildAppleOrigin();
const signInAndGetSessionCookie = async (): Promise<string> => {
const loginResponse = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
)
.send({
query: `
mutation GetLoginTokenFromCredentials(
$email: String!
$password: String!
$origin: String!
) {
getLoginTokenFromCredentials(
email: $email
password: $password
origin: $origin
) {
loginToken {
token
}
}
}
`,
variables: {
email: 'tim@apple.dev',
password: 'tim@apple.dev',
origin,
},
})
.expect(200);
const loginToken =
loginResponse.body.data.getLoginTokenFromCredentials.loginToken.token;
const exchangeResponse = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
)
.send({
query: `
mutation GetAuthTokensFromLoginToken(
$loginToken: String!
$origin: String!
) {
getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) {
tokens {
accessOrWorkspaceAgnosticToken {
token
}
}
}
}
`,
variables: { loginToken, origin },
})
.expect(200);
const sessionCookie = extractSessionCookie(
exchangeResponse.headers['set-cookie'],
);
expect(sessionCookie).toBeDefined();
return sessionCookie as string;
};
describe('Cookie sessions (integration)', () => {
let sessionCookie: string;
beforeAll(async () => {
sessionCookie = await signInAndGetSessionCookie();
});
it('should set a session cookie when exchanging a login token', () => {
expect(sessionCookie).toContain(`${USER_SESSION_COOKIE_NAME}=sess_`);
});
it('should authenticate a request carrying only the session cookie', async () => {
const response = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
)
.set('Cookie', sessionCookie)
.send({ query: CURRENT_USER_QUERY })
.expect(200);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.currentUser.email).toBe('tim@apple.dev');
});
it('should reject a cookie-authenticated request from a foreign origin', async () => {
const response = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
'https://evil.example.org',
)
.set('Cookie', sessionCookie)
.send({ query: CURRENT_USER_QUERY });
expect(response.status).toBe(403);
expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH');
});
it('should reject a cookie-authenticated request sending no origin', async () => {
const response = await request(SERVER_URL)
.post('/metadata')
.set('Host', new URL(origin).host)
.set('Cookie', sessionCookie)
.send({ query: CURRENT_USER_QUERY });
expect(response.status).toBe(403);
expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH');
});
// Mints its own session rather than consuming the shared one, so revoking it
// cannot break the tests above whatever order they run in.
it('should stop authenticating the cookie once signed out', async () => {
const disposableSessionCookie = await signInAndGetSessionCookie();
const signOutResponse = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
)
.set('Cookie', disposableSessionCookie)
.send({ query: SIGN_OUT_MUTATION })
.expect(200);
expect(signOutResponse.body.errors).toBeUndefined();
const afterSignOut = await asWorkspaceRequest(
request(SERVER_URL).post('/metadata'),
)
.set('Cookie', disposableSessionCookie)
.send({ query: CURRENT_USER_QUERY });
expect(afterSignOut.body.data?.currentUser).toBeFalsy();
expect(afterSignOut.body.errors).toBeDefined();
expect(
afterSignOut.body.errors.map(
(graphQLError: { extensions?: { code?: string } }) =>
graphQLError.extensions?.code,
),
).toEqual(
expect.arrayContaining([expect.stringMatching(/UNAUTHENTICATED/)]),
);
});
it('should answer a revoked cookie with an auth error rather than a schema error', async () => {
const disposableSessionCookie = await signInAndGetSessionCookie();
await asWorkspaceRequest(request(SERVER_URL).post('/metadata'))
.set('Cookie', disposableSessionCookie)
.send({ query: SIGN_OUT_MUTATION })
.expect(200);
const response = await asWorkspaceRequest(
request(SERVER_URL).post('/graphql'),
)
.set('Cookie', disposableSessionCookie)
.send({ query: FIND_COMPANIES_QUERY });
expect(response.body.errors).toBeDefined();
const messages = response.body.errors.map(
(graphQLError: { message: string }) => graphQLError.message,
);
const codes = response.body.errors.map(
(graphQLError: { extensions?: { code?: string } }) =>
graphQLError.extensions?.code,
);
// The client only signs out on an auth code. A missing workspace schema
// surfaces as "Cannot query field", which it cannot act on.
expect(messages.join(' ')).not.toContain('Cannot query field');
expect(codes.some((code: string) => AUTH_ERROR_CODES.includes(code))).toBe(
true,
);
});
});

View File

@@ -0,0 +1,81 @@
import { buildAppleWorkspaceOrigin } from 'test/integration/graphql/utils/build-apple-workspace-origin.util';
import { getLoginTokenFromCredentialsQueryFactory } from 'test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util';
import { signUpInWorkspaceOperationFactory } from 'test/integration/graphql/utils/sign-up-in-workspace-operation-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
const STORED_ADDRESS = 'tim@apple.dev';
const IDENTITY_PROVIDER_ADDRESS = 'Tim@Apple.Dev';
const PASSWORD = 'tim@apple.dev';
const countLiveUsersWithAddress = async (address: string): Promise<number> => {
const rows = await global.testDataSource.query(
'SELECT 1 FROM core."user" WHERE lower("email") = lower($1) AND "deletedAt" IS NULL',
[address],
);
return rows.length;
};
describe('user identity is case-insensitive on email (integration)', () => {
it('resolves the already-registered user when the address casing differs', async () => {
const userRepository = getCoreRepository<UserEntity>(UserEntity);
const user = await userRepository.findOne({
where: { email: IDENTITY_PROVIDER_ADDRESS },
});
expect(user?.id).toBe(USER_DATA_SEED_IDS.TIM);
expect(user?.email).toBe(STORED_ADDRESS);
});
it('issues a login token when signing in with a differently-cased address', async () => {
const response = await makeMetadataAPIRequest(
getLoginTokenFromCredentialsQueryFactory({
email: IDENTITY_PROVIDER_ADDRESS,
password: PASSWORD,
origin: buildAppleWorkspaceOrigin(),
}),
null,
).expect(200);
expect(response.body.errors).toBeUndefined();
expect(
response.body.data.getLoginTokenFromCredentials.loginToken.token,
).toBeDefined();
});
it('signs the existing user into the workspace instead of creating a second row', async () => {
expect(await countLiveUsersWithAddress(STORED_ADDRESS)).toBe(1);
const response = await makeMetadataAPIRequest(
signUpInWorkspaceOperationFactory({
email: IDENTITY_PROVIDER_ADDRESS,
password: PASSWORD,
workspaceId: SEED_APPLE_WORKSPACE_ID,
}),
null,
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.signUpInWorkspace.workspace.id).toBe(
SEED_APPLE_WORKSPACE_ID,
);
expect(await countLiveUsersWithAddress(STORED_ADDRESS)).toBe(1);
});
it('refuses a second account whose address differs only by casing', async () => {
await expect(
global.testDataSource.query(
'INSERT INTO core."user" ("firstName", "lastName", "email") VALUES ($1, $2, $3)',
['Case', 'Clash', IDENTITY_PROVIDER_ADDRESS],
),
).rejects.toThrow(/UQ_USER_EMAIL/);
expect(await countLiveUsersWithAddress(STORED_ADDRESS)).toBe(1);
});
});

View File

@@ -0,0 +1,29 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`JWT Asymmetric Signing - new ES256 + kid implementation (integration) rejects a token signed by a revoked kid (publicKey present, revokedAt set) 1`] = `
{
"extensions": {
"code": "UNAUTHENTICATED",
"subCode": "UNAUTHENTICATED",
"userFriendlyMessage": {
"id": Any<String>,
"message": "You must be authenticated to perform this action.",
},
},
"message": "Token invalid.",
}
`;
exports[`JWT Asymmetric Signing - new ES256 + kid implementation (integration) rejects a token whose kid was never registered without leaking a 500 1`] = `
{
"extensions": {
"code": "UNAUTHENTICATED",
"subCode": "UNAUTHENTICATED",
"userFriendlyMessage": {
"id": Any<String>,
"message": "You must be authenticated to perform this action.",
},
},
"message": "Token invalid.",
}
`;

View File

@@ -0,0 +1,266 @@
import { randomUUID } from 'crypto';
import { gql } from 'graphql-tag';
import * as jwt from 'jsonwebtoken';
import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util';
import { makeAdminPanelAPIRequest } from 'test/integration/twenty-config/utils/make-admin-panel-api-request.util';
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import {
PREVIOUS_PRIVATE_KEY_PEM,
PREVIOUS_PUBLIC_KEY_PEM,
} from './jwt-key-rotation.fixture';
const buildAccessTokenPayload = (payload: AccessTokenJwtPayload) => ({
sub: payload.sub,
userId: payload.userId,
workspaceId: payload.workspaceId,
workspaceMemberId: payload.workspaceMemberId,
userWorkspaceId: payload.userWorkspaceId,
authProvider: payload.authProvider,
isImpersonating: false,
type: JwtTokenTypeEnum.ACCESS,
});
const GET_SIGNING_KEYS = gql`
query GetSigningKeys {
getSigningKeys {
signingKeys {
id
publicKey
isCurrent
createdAt
revokedAt
verifyCountInWindow
}
legacyVerifyCountInWindow
verifyWindowDays
}
}
`;
const REVOKE_SIGNING_KEY = gql`
mutation RevokeSigningKey($id: UUID!) {
revokeSigningKey(id: $id) {
id
isCurrent
revokedAt
}
}
`;
describe('Admin panel signing keys (integration)', () => {
let sharedAccessToken: string;
let sharedAccessPayload: AccessTokenJwtPayload;
beforeAll(async () => {
const uniqueEmail = `admin-signing-keys-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
expectToFail: false,
});
const workspaceAgnosticToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await global.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const { data: workspaceData } = await signUpInNewWorkspace({
accessToken: workspaceAgnosticToken,
expectToFail: false,
});
const subdomainUrl =
workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl;
const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token;
const { data: tokensData } = await getAuthTokensFromLoginToken({
loginToken,
origin: subdomainUrl,
expectToFail: false,
});
sharedAccessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
sharedAccessPayload = jwt.decode(
sharedAccessToken,
) as AccessTokenJwtPayload;
});
afterAll(async () => {
try {
await deleteUser({
accessToken: sharedAccessToken,
expectToFail: false,
});
} catch {
/* */
}
});
it('exposes signing keys with current marker and a 7-day window', async () => {
const response = await makeAdminPanelAPIRequest({
query: GET_SIGNING_KEYS,
});
expect(response.body.errors).toBeUndefined();
const payload = response.body.data?.getSigningKeys;
expect(payload).toBeDefined();
expect(payload.verifyWindowDays).toBe(7);
expect(Array.isArray(payload.signingKeys)).toBe(true);
expect(payload.signingKeys.length).toBeGreaterThan(0);
const currentKeys = payload.signingKeys.filter(
(signingKey: { isCurrent: boolean }) => signingKey.isCurrent === true,
);
expect(currentKeys).toHaveLength(1);
expect(currentKeys[0].revokedAt).toBeNull();
expect(typeof currentKeys[0].publicKey).toBe('string');
expect(typeof payload.legacyVerifyCountInWindow).toBe('number');
});
it('revokes a non-current signing key, keeps the current key active, and rejects tokens signed with the revoked kid', async () => {
const seededRow = await global.testDataSource.query(
`SELECT "id" FROM core."signingKey" WHERE "isCurrent" = true LIMIT 1`,
);
const seededCurrentKid: string = seededRow[0].id;
const obsoleteKid = randomUUID();
await global.testDataSource.query(
`INSERT INTO core."signingKey" ("id", "publicKey", "privateKey", "isCurrent")
VALUES ($1, $2, NULL, false)`,
[obsoleteKid, PREVIOUS_PUBLIC_KEY_PEM],
);
const tokenSignedByObsoleteKey = jwt.sign(
buildAccessTokenPayload(sharedAccessPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: obsoleteKid, expiresIn: '5m' },
);
const { data: userBeforeRevoke, errors: userBeforeRevokeErrors } =
await getCurrentUser({
accessToken: tokenSignedByObsoleteKey,
expectToFail: false,
});
expect(userBeforeRevokeErrors).toBeUndefined();
expect(userBeforeRevoke?.currentUser?.id).toBe(sharedAccessPayload.userId);
const response = await makeAdminPanelAPIRequest({
query: REVOKE_SIGNING_KEY,
variables: { id: obsoleteKid },
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data?.revokeSigningKey?.id).toBe(obsoleteKid);
expect(response.body.data?.revokeSigningKey?.isCurrent).toBe(false);
expect(response.body.data?.revokeSigningKey?.revokedAt).not.toBeNull();
const stillCurrentRows = await global.testDataSource.query(
`SELECT "id", "isCurrent", "revokedAt" FROM core."signingKey"
WHERE "id" = $1`,
[seededCurrentKid],
);
expect(stillCurrentRows[0].isCurrent).toBe(true);
expect(stillCurrentRows[0].revokedAt).toBeNull();
const { data: userAfterRevoke, errors: userAfterRevokeErrors } =
await getCurrentUser({
accessToken: tokenSignedByObsoleteKey,
expectToFail: true,
});
expect(userAfterRevoke?.currentUser).toBeFalsy();
expect(userAfterRevokeErrors).toBeDefined();
await global.testDataSource.query(
`DELETE FROM core."signingKey" WHERE "id" = $1`,
[obsoleteKid],
);
});
it('revokes the current signing key and the next sign mints a new current key', async () => {
const before = await global.testDataSource.query(
`SELECT "id" FROM core."signingKey" WHERE "isCurrent" = true LIMIT 1`,
);
const previousCurrentKid: string = before[0].id;
const response = await makeAdminPanelAPIRequest({
query: REVOKE_SIGNING_KEY,
variables: { id: previousCurrentKid },
});
expect(response.body.errors).toBeUndefined();
expect(response.body.data?.revokeSigningKey?.isCurrent).toBe(false);
expect(response.body.data?.revokeSigningKey?.revokedAt).not.toBeNull();
const uniqueEmail = `signing-key-revoke-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
expectToFail: false,
});
const workspaceAgnosticToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await global.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const { data: workspaceData } = await signUpInNewWorkspace({
accessToken: workspaceAgnosticToken,
expectToFail: false,
});
const subdomainUrl =
workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl;
const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token;
const { data: tokensData } = await getAuthTokensFromLoginToken({
loginToken,
origin: subdomainUrl,
expectToFail: false,
});
const accessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
const decoded = decodeJwtCompleteOrThrow(accessToken);
expect(decoded.header.alg).toBe('ES256');
expect(decoded.header.kid).not.toBe(previousCurrentKid);
const { errors } = await getCurrentUser({
accessToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
try {
await deleteUser({ accessToken, expectToFail: false });
} catch {
/* */
}
});
});

View File

@@ -0,0 +1,336 @@
import { randomUUID } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import * as jwt from 'jsonwebtoken';
import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { generateApiKeyToken } from 'test/integration/graphql/utils/generate-api-key-token.util';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { renewApplicationToken } from 'test/integration/graphql/utils/renew-application-token.util';
import { renewToken } from 'test/integration/graphql/utils/renew-token.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util';
import { generateApplicationToken } from 'test/integration/metadata/suites/application/utils/generate-application-token.util';
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { type ApplicationAccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-access-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import {
PREVIOUS_KID,
PREVIOUS_PRIVATE_KEY_PEM,
PREVIOUS_PUBLIC_KEY_PEM,
REVOKED_KID,
} from './jwt-key-rotation.fixture';
const buildAccessTokenPayload = (payload: AccessTokenJwtPayload) => ({
sub: payload.sub,
userId: payload.userId,
workspaceId: payload.workspaceId,
workspaceMemberId: payload.workspaceMemberId,
userWorkspaceId: payload.userWorkspaceId,
authProvider: payload.authProvider,
isImpersonating: false,
type: JwtTokenTypeEnum.ACCESS,
});
describe('JWT Asymmetric Signing - new ES256 + kid implementation (integration)', () => {
let sharedAccessToken: string;
let sharedRefreshToken: string;
let sharedAccessPayload: AccessTokenJwtPayload;
let currentKid: string;
beforeAll(async () => {
const uniqueEmail = `jwt-asymmetric-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
expectToFail: false,
});
const workspaceAgnosticToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
await global.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const { data: workspaceData } = await signUpInNewWorkspace({
accessToken: workspaceAgnosticToken,
expectToFail: false,
});
const subdomainUrl =
workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl;
const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token;
const { data: tokensData } = await getAuthTokensFromLoginToken({
loginToken,
origin: subdomainUrl,
expectToFail: false,
});
sharedAccessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
sharedRefreshToken =
tokensData.getAuthTokensFromLoginToken.tokens.refreshToken.token;
sharedAccessPayload = jwt.decode(
sharedAccessToken,
) as AccessTokenJwtPayload;
currentKid = decodeJwtCompleteOrThrow(sharedAccessToken).header
.kid as string;
});
afterAll(async () => {
if (isNonEmptyString(sharedAccessToken)) {
try {
await deleteUser({
accessToken: sharedAccessToken,
expectToFail: false,
});
} catch {
/* */
}
}
await global.testDataSource.query(
`DELETE FROM core."signingKey" WHERE "id" = ANY($1::uuid[])`,
[[PREVIOUS_KID, REVOKED_KID]],
);
});
it('auto-generates an ES256 signing key on first boot and signs ACCESS tokens with kid + isCurrent row', async () => {
const decoded = decodeJwtCompleteOrThrow(sharedAccessToken);
expect(decoded.header.alg).toBe('ES256');
expect(isNonEmptyString(decoded.header.kid)).toBe(true);
const rows = await global.testDataSource.query(
`SELECT "id", "publicKey", "privateKey", "isCurrent", "revokedAt"
FROM core."signingKey" WHERE "id" = $1`,
[currentKid],
);
expect(rows).toHaveLength(1);
expect(rows[0].isCurrent).toBe(true);
expect(rows[0].revokedAt).toBeNull();
expect(isNonEmptyString(rows[0].publicKey)).toBe(true);
expect(isNonEmptyString(rows[0].privateKey)).toBe(true);
expect(rows[0].publicKey).toMatch(
/^-----BEGIN PUBLIC KEY-----[\s\S]+-----END PUBLIC KEY-----\s*$/,
);
const { data, errors } = await getCurrentUser({
accessToken: sharedAccessToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.currentUser).toBeDefined();
});
it('verifies a token signed by a previously rotated-out kid (privateKey null, public key still present)', async () => {
await global.testDataSource.query(
`INSERT INTO core."signingKey" ("id", "publicKey", "privateKey", "isCurrent")
VALUES ($1, $2, NULL, false)
ON CONFLICT ("id") DO NOTHING`,
[PREVIOUS_KID, PREVIOUS_PUBLIC_KEY_PEM],
);
const tokenSignedByPreviousKey = jwt.sign(
buildAccessTokenPayload(sharedAccessPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: PREVIOUS_KID, expiresIn: '5m' },
);
const decoded = decodeJwtCompleteOrThrow(tokenSignedByPreviousKey);
expect(decoded.header.alg).toBe('ES256');
expect(decoded.header.kid).toBe(PREVIOUS_KID);
expect(decoded.header.kid).not.toBe(currentKid);
const { data, errors } = await getCurrentUser({
accessToken: tokenSignedByPreviousKey,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.currentUser?.id).toBe(sharedAccessPayload.userId);
});
it('rejects a token signed by a revoked kid (publicKey present, revokedAt set)', async () => {
await global.testDataSource.query(
`INSERT INTO core."signingKey" ("id", "publicKey", "privateKey", "isCurrent", "revokedAt")
VALUES ($1, $2, NULL, false, NOW())
ON CONFLICT ("id") DO UPDATE SET "revokedAt" = NOW(), "isCurrent" = false`,
[REVOKED_KID, PREVIOUS_PUBLIC_KEY_PEM],
);
const tokenSignedByRevokedKey = jwt.sign(
buildAccessTokenPayload(sharedAccessPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: REVOKED_KID, expiresIn: '5m' },
);
const { data, errors } = await getCurrentUser({
accessToken: tokenSignedByRevokedKey,
expectToFail: true,
});
expect(data?.currentUser).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('rejects a token whose kid was never registered without leaking a 500', async () => {
const unknownKid = '00000000-0000-4000-8000-000000000099';
const tokenSignedByOrphanKey = jwt.sign(
buildAccessTokenPayload(sharedAccessPayload),
PREVIOUS_PRIVATE_KEY_PEM,
{ algorithm: 'ES256', keyid: unknownKid, expiresIn: '5m' },
);
const { data, errors } = await getCurrentUser({
accessToken: tokenSignedByOrphanKey,
expectToFail: true,
});
expect(data?.currentUser).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors });
});
it('round-trips a new ES256 REFRESH token through renewToken', async () => {
const response = await renewToken(sharedRefreshToken);
expect(response.body.errors).toBeUndefined();
expect(
isNonEmptyString(
response.body.data?.renewToken.tokens.accessOrWorkspaceAgnosticToken
.token,
),
).toBe(true);
expect(
isNonEmptyString(
response.body.data?.renewToken.tokens.refreshToken.token,
),
).toBe(true);
});
});
describe('JWT Asymmetric Signing - seeded-workspace tokens (integration)', () => {
const seededApiKeyId = API_KEY_DATA_SEED_IDS.ID_1;
const seededWorkspaceId = SEED_APPLE_WORKSPACE_ID;
let seededCurrentKid: string;
let seededApplicationId: string;
beforeAll(async () => {
const [{ id: currentKidRow }] = await global.testDataSource.query(
`SELECT "id" FROM core."signingKey" WHERE "isCurrent" = true LIMIT 1`,
);
seededCurrentKid = currentKidRow;
const { data: applicationsData } = await findManyApplications({
expectToFail: false,
});
const firstApplication = applicationsData.findManyApplications[0];
expect(firstApplication).toBeDefined();
seededApplicationId = firstApplication.id;
});
it('signs new API_KEY tokens with ES256 + kid and authenticates against the GraphQL API', async () => {
const response = await generateApiKeyToken({
apiKeyId: seededApiKeyId,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(response.body.errors).toBeUndefined();
const apiKeyToken: string =
response.body.data?.generateApiKeyToken.token ?? '';
expect(isNonEmptyString(apiKeyToken)).toBe(true);
const decoded = decodeJwtCompleteOrThrow(apiKeyToken);
expect(decoded.header.alg).toBe('ES256');
expect(decoded.header.kid).toBe(seededCurrentKid);
const { data, errors } = await findManyApplications({
accessToken: apiKeyToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.findManyApplications).toBeDefined();
});
it('signs new APPLICATION_ACCESS + APPLICATION_REFRESH tokens with ES256 + kid via generateApplicationToken', async () => {
const { data, errors } = await generateApplicationToken({
applicationId: seededApplicationId,
expectToFail: false,
});
expect(errors).toBeUndefined();
const { applicationAccessToken, applicationRefreshToken } =
data.generateApplicationToken;
const decodedAccess = decodeJwtCompleteOrThrow(
applicationAccessToken.token,
);
const decodedRefresh = decodeJwtCompleteOrThrow(
applicationRefreshToken.token,
);
expect(decodedAccess.header.alg).toBe('ES256');
expect(decodedAccess.header.kid).toBe(seededCurrentKid);
expect(decodedRefresh.header.alg).toBe('ES256');
expect(decodedRefresh.header.kid).toBe(seededCurrentKid);
const accessPayload = jwt.decode(
applicationAccessToken.token,
) as ApplicationAccessTokenJwtPayload;
expect(accessPayload.type).toBe(JwtTokenTypeEnum.APPLICATION_ACCESS);
expect(accessPayload.workspaceId).toBe(seededWorkspaceId);
expect(accessPayload.applicationId).toBe(seededApplicationId);
});
it('round-trips a new ES256 APPLICATION_REFRESH token through renewApplicationToken', async () => {
const { data } = await generateApplicationToken({
applicationId: seededApplicationId,
expectToFail: false,
});
const response = await renewApplicationToken({
applicationRefreshToken:
data.generateApplicationToken.applicationRefreshToken.token,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(response.body.errors).toBeUndefined();
const renewed = response.body.data?.renewApplicationToken;
expect(isNonEmptyString(renewed?.applicationAccessToken.token)).toBe(true);
expect(isNonEmptyString(renewed?.applicationRefreshToken.token)).toBe(true);
expect(
decodeJwtCompleteOrThrow(renewed.applicationAccessToken.token).header.alg,
).toBe('ES256');
});
});

View File

@@ -0,0 +1,13 @@
export const PREVIOUS_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgc39GS4ubhdkNWp7L
b8sC0ROUVBGNO8DvltFB9yHCDLWhRANCAASnreXZnMdvXRwuymcxYO0puFCnIQ9n
xySPRmivfklUKwm00ZMXX3AxStxeqp0vr6qOu54JpPdRFfdeYp5E53OF
-----END PRIVATE KEY-----`;
export const PREVIOUS_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEp63l2ZzHb10cLspnMWDtKbhQpyEP
Z8ckj0Zor35JVCsJtNGTF19wMUrcXqqdL6+qjrueCaT3URX3XmKeROdzhQ==
-----END PUBLIC KEY-----`;
export const PREVIOUS_KID = '00000000-0000-4000-8000-000000000001';
export const REVOKED_KID = '00000000-0000-4000-8000-000000000002';

View File

@@ -0,0 +1,323 @@
import { randomUUID } from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import * as jwt from 'jsonwebtoken';
import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { forgeLegacyHs256Token } from 'test/integration/graphql/utils/forge-legacy-hs256-token.util';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { renewApplicationToken } from 'test/integration/graphql/utils/renew-application-token.util';
import { renewToken } from 'test/integration/graphql/utils/renew-token.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { signUpInNewWorkspace } from 'test/integration/graphql/utils/sign-up-in-new-workspace.util';
import { type AccessTokenJwtPayload } from 'src/engine/core-modules/auth/types/access-token-jwt-payload.type';
import { type ApplicationRefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/application-refresh-token-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { type LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type';
import { type RefreshTokenJwtPayload } from 'src/engine/core-modules/auth/types/refresh-token-jwt-payload.type';
import { type WorkspaceAgnosticTokenJwtPayload } from 'src/engine/core-modules/auth/types/workspace-agnostic-token-jwt-payload.type';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const buildAccessTokenPayload = (payload: AccessTokenJwtPayload) => ({
sub: payload.sub,
userId: payload.userId,
workspaceId: payload.workspaceId,
workspaceMemberId: payload.workspaceMemberId,
userWorkspaceId: payload.userWorkspaceId,
authProvider: payload.authProvider,
isImpersonating: false,
type: JwtTokenTypeEnum.ACCESS,
});
describe('JWT Legacy HS256 no-kid fallback (integration)', () => {
let sharedAccessToken: string;
let sharedAccessPayload: AccessTokenJwtPayload;
let sharedRefreshPayload: RefreshTokenJwtPayload;
let sharedLoginPayload: LoginTokenJwtPayload;
let sharedWorkspaceAgnosticPayload: WorkspaceAgnosticTokenJwtPayload;
let sharedSubdomainUrl: string;
beforeAll(async () => {
const uniqueEmail = `jwt-legacy-${randomUUID()}@example.com`;
const { data: signUpData } = await signUp({
input: { email: uniqueEmail, password: 'Test123!@#' },
expectToFail: false,
});
const workspaceAgnosticToken =
signUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
sharedWorkspaceAgnosticPayload = jwt.decode(
workspaceAgnosticToken,
) as WorkspaceAgnosticTokenJwtPayload;
await global.testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[uniqueEmail],
);
const { data: workspaceData } = await signUpInNewWorkspace({
accessToken: workspaceAgnosticToken,
expectToFail: false,
});
sharedSubdomainUrl =
workspaceData.signUpInNewWorkspace.workspace.workspaceUrls.subdomainUrl;
const loginToken = workspaceData.signUpInNewWorkspace.loginToken.token;
sharedLoginPayload = jwt.decode(loginToken) as LoginTokenJwtPayload;
const { data: tokensData } = await getAuthTokensFromLoginToken({
loginToken,
origin: sharedSubdomainUrl,
expectToFail: false,
});
sharedAccessToken =
tokensData.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token;
sharedAccessPayload = jwt.decode(
sharedAccessToken,
) as AccessTokenJwtPayload;
sharedRefreshPayload = jwt.decode(
tokensData.getAuthTokensFromLoginToken.tokens.refreshToken.token,
) as RefreshTokenJwtPayload;
});
afterAll(async () => {
if (isNonEmptyString(sharedAccessToken)) {
try {
await deleteUser({
accessToken: sharedAccessToken,
expectToFail: false,
});
} catch {
/* */
}
}
});
it('verifies a hand-crafted no-kid HS256 ACCESS token via the legacy fallback', async () => {
const legacyHs256Token = forgeLegacyHs256Token(
buildAccessTokenPayload(sharedAccessPayload),
sharedAccessPayload.workspaceId,
);
const decoded = decodeJwtCompleteOrThrow(legacyHs256Token);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await getCurrentUser({
accessToken: legacyHs256Token,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.currentUser?.id).toBe(sharedAccessPayload.userId);
});
it('verifies a hand-crafted no-kid HS256 LOGIN token via the legacy fallback (round-trip through getAuthTokensFromLoginToken)', async () => {
const forgedLoginPayload: LoginTokenJwtPayload = {
sub: sharedLoginPayload.sub,
type: JwtTokenTypeEnum.LOGIN,
workspaceId: sharedLoginPayload.workspaceId,
authProvider:
sharedLoginPayload.authProvider ?? AuthProviderEnum.Password,
};
const forgedToken = forgeLegacyHs256Token(
forgedLoginPayload,
sharedLoginPayload.workspaceId,
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await getAuthTokensFromLoginToken({
loginToken: forgedToken,
origin: sharedSubdomainUrl,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(
isNonEmptyString(
data?.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken
.token,
),
).toBe(true);
});
it('verifies a hand-crafted no-kid HS256 WORKSPACE_AGNOSTIC token via the legacy fallback (round-trip through signUpInNewWorkspace)', async () => {
const forgedAgnosticPayload: WorkspaceAgnosticTokenJwtPayload = {
sub: sharedWorkspaceAgnosticPayload.sub,
userId: sharedWorkspaceAgnosticPayload.userId,
type: JwtTokenTypeEnum.WORKSPACE_AGNOSTIC,
authProvider:
sharedWorkspaceAgnosticPayload.authProvider ??
AuthProviderEnum.Password,
};
const forgedToken = forgeLegacyHs256Token(
forgedAgnosticPayload,
sharedWorkspaceAgnosticPayload.userId,
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await signUpInNewWorkspace({
accessToken: forgedToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(isNonEmptyString(data?.signUpInNewWorkspace.workspace.id)).toBe(
true,
);
});
it('verifies a hand-crafted no-kid HS256 REFRESH token via the legacy fallback (round-trip through renewToken)', async () => {
expect(isNonEmptyString(sharedRefreshPayload.jti)).toBe(true);
const forgedRefreshPayload = {
sub: sharedRefreshPayload.sub,
type: JwtTokenTypeEnum.REFRESH,
userId: sharedRefreshPayload.userId,
workspaceId: sharedRefreshPayload.workspaceId,
authProvider:
sharedRefreshPayload.authProvider ?? AuthProviderEnum.Password,
targetedTokenType:
sharedRefreshPayload.targetedTokenType ?? JwtTokenTypeEnum.ACCESS,
};
const forgedToken = forgeLegacyHs256Token(
forgedRefreshPayload,
sharedRefreshPayload.workspaceId ?? sharedRefreshPayload.userId,
{ expiresIn: '5m', jwtid: sharedRefreshPayload.jti },
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const response = await renewToken(forgedToken);
expect(response.body.errors).toBeUndefined();
expect(
isNonEmptyString(
response.body.data?.renewToken.tokens.accessOrWorkspaceAgnosticToken
.token,
),
).toBe(true);
});
});
describe('JWT Legacy HS256 no-kid fallback - seeded-workspace tokens (integration)', () => {
const seededApiKeyId = API_KEY_DATA_SEED_IDS.ID_1;
const seededWorkspaceId = SEED_APPLE_WORKSPACE_ID;
let seededApplicationId: string;
beforeAll(async () => {
const { data: applicationsData } = await findManyApplications({
expectToFail: false,
});
const firstApplication = applicationsData.findManyApplications[0];
expect(firstApplication).toBeDefined();
seededApplicationId = firstApplication.id;
});
it('verifies the seeded legacy HS256 no-kid API_KEY token via the legacy fallback', async () => {
const decoded = decodeJwtCompleteOrThrow(API_KEY_ACCESS_TOKEN);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await findManyApplications({
accessToken: API_KEY_ACCESS_TOKEN,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.findManyApplications).toBeDefined();
});
it('verifies a hand-crafted no-kid HS256 API_KEY token via the legacy fallback', async () => {
const forgedPayload = {
sub: seededWorkspaceId,
type: JwtTokenTypeEnum.API_KEY,
workspaceId: seededWorkspaceId,
};
const forgedToken = forgeLegacyHs256Token(
forgedPayload,
seededWorkspaceId,
{
expiresIn: '5m',
jwtid: seededApiKeyId,
},
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const { data, errors } = await findManyApplications({
accessToken: forgedToken,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(data?.findManyApplications).toBeDefined();
});
it('verifies a hand-crafted no-kid HS256 APPLICATION_REFRESH token via the legacy fallback (round-trip through renewApplicationToken)', async () => {
const forgedPayload: ApplicationRefreshTokenJwtPayload = {
sub: seededApplicationId,
type: JwtTokenTypeEnum.APPLICATION_REFRESH,
workspaceId: seededWorkspaceId,
applicationId: seededApplicationId,
};
const forgedToken = forgeLegacyHs256Token(
forgedPayload as unknown as Record<string, unknown> & {
type: JwtTokenTypeEnum;
},
seededWorkspaceId,
);
const decoded = decodeJwtCompleteOrThrow(forgedToken);
expect(decoded.header.alg).toBe('HS256');
expect(decoded.header.kid).toBeUndefined();
const response = await renewApplicationToken({
applicationRefreshToken: forgedToken,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(response.body.errors).toBeUndefined();
const renewed = response.body.data?.renewApplicationToken;
expect(isNonEmptyString(renewed?.applicationAccessToken.token)).toBe(true);
expect(isNonEmptyString(renewed?.applicationRefreshToken.token)).toBe(true);
});
});

View File

@@ -0,0 +1,105 @@
/* @license Enterprise */
import { isNonEmptyString } from '@sniptt/guards';
import { decodeJwtCompleteOrThrow } from 'test/integration/graphql/utils/decode-jwt-complete-or-throw.util';
import { findManyApplications } from 'test/integration/graphql/utils/find-many-applications.util';
import { generateApiKeyToken } from 'test/integration/graphql/utils/generate-api-key-token.util';
import { deleteConfigVariable } from 'test/integration/twenty-config/utils/delete-config-variable.util';
import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util';
import { expectEventually } from 'test/integration/utils/expect-eventually.util';
import { RotateSigningKeysCronJob } from 'src/engine/core-modules/jwt/crons/jobs/rotate-signing-keys.cron.job';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { type MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { API_KEY_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/api-key-data-seeds.constant';
const SIGNING_KEY_ROTATION_DAYS_KEY = 'SIGNING_KEY_ROTATION_DAYS';
describe('RotateSigningKeysCronJob (integration)', () => {
const seededApiKeyId = API_KEY_DATA_SEED_IDS.ID_1;
let cronQueue: MessageQueueService;
beforeAll(() => {
cronQueue = global.app.get<MessageQueueService>(
getQueueToken(MessageQueue.cronQueue),
);
});
afterAll(async () => {
await deleteConfigVariable({
input: { key: SIGNING_KEY_ROTATION_DAYS_KEY },
}).catch(() => {});
});
it('rotates the current JWT signing key when the cron command runs and keeps verifying tokens signed by the previous key', async () => {
const initialTokenResponse = await generateApiKeyToken({
apiKeyId: seededApiKeyId,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(initialTokenResponse.body.errors).toBeUndefined();
const initialApiKeyToken: string =
initialTokenResponse.body.data?.generateApiKeyToken.token ?? '';
expect(isNonEmptyString(initialApiKeyToken)).toBe(true);
const initialKid = decodeJwtCompleteOrThrow(initialApiKeyToken).header
.kid as string;
expect(isNonEmptyString(initialKid)).toBe(true);
const initialAuthCall = await findManyApplications({
accessToken: initialApiKeyToken,
expectToFail: false,
});
expect(initialAuthCall.errors).toBeUndefined();
expect(initialAuthCall.data?.findManyApplications).toBeDefined();
await updateConfigVariable({
input: { key: SIGNING_KEY_ROTATION_DAYS_KEY, value: 0 },
});
await cronQueue.add(RotateSigningKeysCronJob.name, {});
let rotatedApiKeyToken = '';
await expectEventually(async () => {
const rotatedTokenResponse = await generateApiKeyToken({
apiKeyId: seededApiKeyId,
accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN,
});
expect(rotatedTokenResponse.body.errors).toBeUndefined();
rotatedApiKeyToken =
rotatedTokenResponse.body.data?.generateApiKeyToken.token ?? '';
expect(isNonEmptyString(rotatedApiKeyToken)).toBe(true);
const rotatedKid = decodeJwtCompleteOrThrow(rotatedApiKeyToken).header
.kid as string;
expect(isNonEmptyString(rotatedKid)).toBe(true);
expect(rotatedKid).not.toBe(initialKid);
});
const callWithPreviousToken = await findManyApplications({
accessToken: initialApiKeyToken,
expectToFail: false,
});
expect(callWithPreviousToken.errors).toBeUndefined();
expect(callWithPreviousToken.data?.findManyApplications).toBeDefined();
const callWithRotatedToken = await findManyApplications({
accessToken: rotatedApiKeyToken,
expectToFail: false,
});
expect(callWithRotatedToken.errors).toBeUndefined();
expect(callWithRotatedToken.data?.findManyApplications).toBeDefined();
});
});

View File

@@ -0,0 +1,25 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`signUpInWorkspace into a non-active workspace (integration) rejects a brand-new user joining a suspended workspace with the workspace-readiness message 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN_EXCEPTION",
"userFriendlyMessage": "Workspace is not ready to welcome new members",
},
"message": "Workspace is not ready to welcome new members",
"name": "ForbiddenError",
}
`;
exports[`signUpInWorkspace into a non-active workspace (integration) rejects an existing user joining a suspended workspace with the workspace-readiness message 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN_EXCEPTION",
"userFriendlyMessage": "Workspace is not ready to welcome new members",
},
"message": "Workspace is not ready to welcome new members",
"name": "ForbiddenError",
}
`;

View File

@@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`signUpInWorkspace with an expired personal invitation (integration) denies access when the personal invitation is expired 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "FORBIDDEN_EXCEPTION",
"userFriendlyMessage": "User does not have access to this workspace",
},
"message": "User does not have access to this workspace",
"name": "ForbiddenError",
}
`;

View File

@@ -0,0 +1,36 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Successful User Sign Up (integration) should fail to sign up with duplicate email 1`] = `
{
"extensions": {
"code": "FORBIDDEN",
"subCode": "USER_ALREADY_EXISTS",
"userFriendlyMessage": "User already exists",
},
"message": "User already exists",
"name": "ForbiddenError",
}
`;
exports[`Successful User Sign Up (integration) should fail to sign up with invalid email 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"userFriendlyMessage": "An error occurred.",
},
"message": "email must be an email",
"name": "UserInputError",
}
`;
exports[`Successful User Sign Up (integration) should fail to sign up with weak password 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "INVALID_INPUT",
"userFriendlyMessage": "Password too weak",
},
"message": "Password too weak",
"name": "UserInputError",
}
`;

View File

@@ -0,0 +1,53 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Successful User Sign Up (integration) should sign up, delete and signup same new user successfully 1`] = `
{
"canAccessFullAdminPanel": false,
"canImpersonate": false,
"createdAt": Any<String>,
"currentUserWorkspace": null,
"currentWorkspace": null,
"deletedAt": null,
"disabled": false,
"email": "test-123@example.com",
"firstName": "",
"id": Any<String>,
"isEmailVerified": false,
"lastName": "",
"locale": "en",
"updatedAt": Any<String>,
}
`;
exports[`Successful User Sign Up (integration) should sign up, delete and signup same new user successfully 2`] = `
{
"extensions": {
"code": "UNAUTHENTICATED",
"subCode": "USER_NOT_FOUND",
"userFriendlyMessage": {
"id": Any<String>,
"message": "User not found.",
},
},
"message": "User not found",
}
`;
exports[`Successful User Sign Up (integration) should sign up, delete and signup same new user successfully 3`] = `
{
"canAccessFullAdminPanel": false,
"canImpersonate": false,
"createdAt": Any<String>,
"currentUserWorkspace": null,
"currentWorkspace": null,
"deletedAt": null,
"disabled": false,
"email": "test-123@example.com",
"firstName": "",
"id": Any<String>,
"isEmailVerified": false,
"lastName": "",
"locale": "en",
"updatedAt": Any<String>,
}
`;

View File

@@ -0,0 +1,59 @@
import request from 'supertest';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { signUpOperationFactory } from 'test/integration/graphql/utils/sign-up-operation-factory.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const client = request(`http://localhost:${APP_PORT}`);
describe('signUpInWorkspace into a non-active workspace (integration)', () => {
const existingUserEmail = `existing-non-active-${Date.now()}@example.com`;
const password = 'Test123!@#';
beforeAll(async () => {
await signUp({
input: { email: existingUserEmail, password },
expectToFail: false,
});
await testDataSource.query(
'UPDATE core.workspace SET "activationStatus" = $1 WHERE id = $2',
[WorkspaceActivationStatus.SUSPENDED, SEED_APPLE_WORKSPACE_ID],
);
});
afterAll(async () => {
await testDataSource.query(
'UPDATE core.workspace SET "activationStatus" = $1 WHERE id = $2',
[WorkspaceActivationStatus.ACTIVE, SEED_APPLE_WORKSPACE_ID],
);
await testDataSource.query('DELETE FROM core."user" WHERE email = $1', [
existingUserEmail,
]);
});
it('rejects an existing user joining a suspended workspace with the workspace-readiness message', async () => {
const response = await client.post('/metadata').send(
signUpOperationFactory({
email: existingUserEmail,
password,
}),
);
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
it('rejects a brand-new user joining a suspended workspace with the workspace-readiness message', async () => {
const response = await client.post('/metadata').send(
signUpOperationFactory({
email: `new-non-active-${Date.now()}@example.com`,
password,
}),
);
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
});

View File

@@ -0,0 +1,40 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import {
deleteWorkspaceInvitationsByEmail,
seedWorkspaceInvitation,
} from 'test/integration/graphql/utils/seed-workspace-invitation.util';
import { signUpInWorkspaceOperationFactory } from 'test/integration/graphql/utils/sign-up-in-workspace-operation-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
describe('signUpInWorkspace with an expired personal invitation (integration)', () => {
const email = `expired-invite-signup-${Date.now()}@example.com`;
const token = `expired-invite-signup-token-${Date.now()}`;
beforeAll(() =>
seedWorkspaceInvitation({
email,
value: token,
expiresAt: new Date(Date.now() - ONE_HOUR_IN_MS),
}),
);
afterAll(() => deleteWorkspaceInvitationsByEmail({ email }));
it('denies access when the personal invitation is expired', async () => {
const response = await makeMetadataAPIRequest(
signUpInWorkspaceOperationFactory({
email,
workspaceId: SEED_APPLE_WORKSPACE_ID,
workspacePersonalInviteToken: token,
}),
undefined,
);
expect(response.body.data?.signUpInWorkspace).toBeFalsy();
expectOneNotInternalServerErrorSnapshot({ errors: response.body.errors });
});
});

View File

@@ -0,0 +1,60 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
describe('Successful User Sign Up (integration)', () => {
it('should fail to sign up with invalid email', async () => {
const { errors } = await signUp({
input: {
email: 'invalid-email',
password: 'Test123!@#',
},
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
it('should fail to sign up with duplicate email', async () => {
const testEmail = `test-duplicate@example.com`;
const { data: firstSignUp } = await signUp({
input: {
email: testEmail,
password: 'Test123!@#',
},
expectToFail: false,
});
expect(
firstSignUp.signUp.tokens.accessOrWorkspaceAgnosticToken.token,
).toBeDefined();
const { errors } = await signUp({
input: {
email: testEmail,
password: 'AnotherPassword123!',
},
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
it('should fail to sign up with weak password', async () => {
const { errors } = await signUp({
input: {
email: `test-123@example.com`,
password: '123',
},
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
});
});
});

View File

@@ -0,0 +1,38 @@
import { randomUUID } from 'crypto';
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { getOnboardingStatus } from 'test/integration/graphql/utils/get-onboarding-status.util';
import { signUpInWorkspaceAndGetAccessToken } from 'test/integration/graphql/utils/sign-up-in-workspace-and-get-access-token.util';
import { isDefined } from 'twenty-shared/utils';
import { OnboardingStatus } from 'src/engine/core-modules/onboarding/enums/onboarding-status.enum';
describe('Onboarding status when signing up in an existing workspace (integration)', () => {
let createdUserAccessToken: string | undefined;
afterEach(async () => {
if (isDefined(createdUserAccessToken)) {
await deleteUser({
accessToken: createdUserAccessToken,
expectToFail: false,
});
createdUserAccessToken = undefined;
}
});
it('should start with the connect-account step for a new user joining through an invite', async () => {
createdUserAccessToken = await signUpInWorkspaceAndGetAccessToken(
`invited-onboarding-status-${randomUUID()}@example.com`,
);
const {
data: { currentUser },
} = await getOnboardingStatus({
accessToken: createdUserAccessToken,
expectToFail: false,
});
expect(currentUser.onboardingStatus).toBe(OnboardingStatus.SYNC_EMAIL);
});
});

View File

@@ -0,0 +1,72 @@
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { getAuthTokensFromLoginToken } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.util';
import {
deleteWorkspaceInvitationsByEmail,
seedWorkspaceInvitation,
} from 'test/integration/graphql/utils/seed-workspace-invitation.util';
import { signUpInWorkspaceOperationFactory } from 'test/integration/graphql/utils/sign-up-in-workspace-operation-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
describe('signUpInWorkspace with a valid personal invitation (integration)', () => {
const email = `valid-invite-signup-${Date.now()}@example.com`;
const token = `valid-invite-signup-token-${Date.now()}`;
let accessToken: string | undefined;
beforeAll(() =>
seedWorkspaceInvitation({
email,
value: token,
expiresAt: new Date(Date.now() + ONE_HOUR_IN_MS),
}),
);
afterAll(async () => {
if (accessToken) {
await deleteUser({ accessToken, expectToFail: false });
}
await deleteWorkspaceInvitationsByEmail({ email });
});
it('grants access when the personal invitation is still valid', async () => {
const response = await makeMetadataAPIRequest(
signUpInWorkspaceOperationFactory({
email,
workspaceId: SEED_APPLE_WORKSPACE_ID,
workspacePersonalInviteToken: token,
}),
undefined,
);
expect(response.body.errors).toBeUndefined();
const signUpPayload = response.body.data.signUpInWorkspace;
expect(signUpPayload.workspace.id).toBe(SEED_APPLE_WORKSPACE_ID);
expect(signUpPayload.loginToken.token).toBeDefined();
await testDataSource.query(
'UPDATE core."user" SET "isEmailVerified" = true WHERE email = $1',
[email],
);
const {
data: { getAuthTokensFromLoginToken: authTokensData },
} = await getAuthTokensFromLoginToken({
loginToken: signUpPayload.loginToken.token,
origin:
signUpPayload.workspace.workspaceUrls?.subdomainUrl ??
'http://localhost:3001',
expectToFail: false,
});
accessToken = authTokensData.tokens.accessOrWorkspaceAgnosticToken.token;
expect(accessToken).toBeDefined();
});
});

View File

@@ -0,0 +1,95 @@
import { deleteUser } from 'test/integration/graphql/utils/delete-user.util';
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util';
import { signUp } from 'test/integration/graphql/utils/sign-up.util';
import { extractRecordIdsAndDatesAsExpectAny } from 'test/utils/extract-record-ids-and-dates-as-expect-any';
import { isDefined } from 'twenty-shared/utils';
import { type SignUpInput } from 'src/engine/core-modules/auth/dto/sign-up.input';
describe('Successful User Sign Up (integration)', () => {
let createdUserAccessToken: string | undefined;
afterEach(async () => {
if (isDefined(createdUserAccessToken)) {
await deleteUser({
accessToken: createdUserAccessToken,
expectToFail: false,
});
createdUserAccessToken = undefined;
}
});
it('should sign up, delete and signup same new user successfully', async () => {
const input: SignUpInput = {
email: `test-123@example.com`,
password: 'Test123!@#',
};
const { data: firstSignUpData } = await signUp({
input,
expectToFail: false,
});
createdUserAccessToken =
firstSignUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
expect(
firstSignUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token,
).toBeDefined();
expect(firstSignUpData.signUp.tokens.refreshToken.token).toBeDefined();
expect(
firstSignUpData.signUp.availableWorkspaces.availableWorkspacesForSignIn,
).toEqual([]);
expect(
firstSignUpData.signUp.availableWorkspaces.availableWorkspacesForSignUp,
).toEqual([]);
const {
data: { currentUser: currentUserAfterSignUp },
} = await getCurrentUser({
accessToken: createdUserAccessToken,
expectToFail: false,
});
expect(currentUserAfterSignUp.deletedAt).toBeNull();
expect(currentUserAfterSignUp).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({ ...currentUserAfterSignUp }),
);
await deleteUser({
accessToken: createdUserAccessToken,
expectToFail: false,
});
const { errors: getCurrentUserAfterDeleteErrors } = await getCurrentUser({
accessToken: createdUserAccessToken,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors: getCurrentUserAfterDeleteErrors,
});
const { data: secondSignUpData } = await signUp({
input,
expectToFail: false,
});
createdUserAccessToken =
secondSignUpData.signUp.tokens.accessOrWorkspaceAgnosticToken.token;
const {
data: { currentUser: currentUserAfterSecondSignUp },
} = await getCurrentUser({
accessToken: createdUserAccessToken,
expectToFail: false,
});
expect(currentUserAfterSecondSignUp.deletedAt).toBeNull();
expect(currentUserAfterSecondSignUp).toMatchSnapshot(
extractRecordIdsAndDatesAsExpectAny({ ...currentUserAfterSecondSignUp }),
);
});
});

View File

@@ -0,0 +1,171 @@
import crypto from 'crypto';
import { type DataSource } from 'typeorm';
import { getAuthTokensFromSsoExchangeToken } from 'test/integration/graphql/utils/get-auth-tokens-from-sso-exchange-token.util';
import { AppTokenType } from 'src/engine/core-modules/app-token/app-token.entity';
import { AuthExceptionCode } from 'src/engine/core-modules/auth/auth.exception';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
const hashToken = (token: string) =>
crypto.createHash('sha256').update(token).digest('hex');
const expectUniformInvalidTokenError = (errors: { message: string }[]) => {
expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({
message: 'Invalid SSO exchange token',
extensions: { subCode: AuthExceptionCode.INVALID_INPUT },
});
};
describe('SSO exchange token redemption (integration)', () => {
let dataSource: DataSource;
const seedSsoExchangeToken = async ({
expiresAt = new Date(Date.now() + 5 * 60 * 1000),
type = AppTokenType.SsoExchangeToken,
revokedAt = null,
}: {
expiresAt?: Date;
type?: AppTokenType;
revokedAt?: Date | null;
} = {}): Promise<string> => {
const plainToken = crypto.randomBytes(32).toString('hex');
await dataSource.query(
`INSERT INTO core."appToken" ("userId", "type", "value", "expiresAt", "revokedAt", "context")
VALUES ($1, $2, $3, $4, $5, $6)`,
[
USER_DATA_SEED_IDS.JANE,
type,
hashToken(plainToken),
expiresAt,
revokedAt,
JSON.stringify({ authProvider: AuthProviderEnum.Google }),
],
);
return plainToken;
};
const countRemainingRows = async (
plainToken: string,
type: AppTokenType = AppTokenType.SsoExchangeToken,
): Promise<number> => {
const rows = await dataSource.query(
`SELECT 1 FROM core."appToken" WHERE "value" = $1 AND "type" = $2`,
[hashToken(plainToken), type],
);
return rows.length;
};
beforeAll(() => {
dataSource = global.testDataSource;
});
it('should exchange a valid token for a token pair and consume it', async () => {
const plainToken = await seedSsoExchangeToken();
const { data } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: false,
});
expect(
data.getAuthTokensFromSSOExchangeToken.tokens
.accessOrWorkspaceAgnosticToken.token,
).toBeDefined();
expect(
data.getAuthTokensFromSSOExchangeToken.tokens.refreshToken.token,
).toBeDefined();
expect(await countRemainingRows(plainToken)).toBe(0);
});
it('should reject a second redemption of the same token', async () => {
const plainToken = await seedSsoExchangeToken();
await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: false,
});
const { errors } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: true,
});
expectUniformInvalidTokenError(errors);
});
it('should reject an expired token', async () => {
const plainToken = await seedSsoExchangeToken({
expiresAt: new Date(Date.now() - 1000),
});
const { errors } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: true,
});
expectUniformInvalidTokenError(errors);
expect(await countRemainingRows(plainToken)).toBe(0);
});
it('should reject a revoked token without consuming it', async () => {
const plainToken = await seedSsoExchangeToken({
revokedAt: new Date(),
});
const { errors } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: true,
});
expectUniformInvalidTokenError(errors);
expect(await countRemainingRows(plainToken)).toBe(1);
});
it('should not redeem an app token of another type sharing the same value', async () => {
const plainToken = await seedSsoExchangeToken({
type: AppTokenType.EmailVerificationToken,
});
const { errors } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: plainToken,
expectToFail: true,
});
expectUniformInvalidTokenError(errors);
expect(
await countRemainingRows(plainToken, AppTokenType.EmailVerificationToken),
).toBe(1);
});
it('should reject an unknown token', async () => {
const { errors } = await getAuthTokensFromSsoExchangeToken({
ssoExchangeToken: crypto.randomBytes(32).toString('hex'),
expectToFail: true,
});
expectUniformInvalidTokenError(errors);
});
it('should let exactly one of several concurrent redemptions succeed', async () => {
const plainToken = await seedSsoExchangeToken();
const responses = await Promise.all(
Array.from({ length: 5 }, () =>
getAuthTokensFromSsoExchangeToken({ ssoExchangeToken: plainToken }),
),
);
const succeeded = responses.filter(
(response) => response.data?.getAuthTokensFromSSOExchangeToken,
);
expect(succeeded).toHaveLength(1);
expect(await countRemainingRows(plainToken)).toBe(0);
});
});

View File

@@ -0,0 +1,117 @@
import { authenticator } from 'otplib';
import { initiateOtpProvisioningForAuthenticatedUser } from 'test/integration/graphql/utils/initiate-otp-provisioning-for-authenticated-user.util';
import { verifyTwoFactorAuthenticationMethod } from 'test/integration/graphql/utils/verify-two-factor-authentication-method.util';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
const TOTP_STEP_DURATION_MS = 30_000;
const MINIMUM_STEP_REMAINING_MS = 5_000;
const generateTokenAtEpoch = (secret: string, epochMs: number): string =>
authenticator.clone({ epoch: epochMs }).generate(secret);
// A previous-step token generated right before a step boundary would already be
// two steps old once the server validates it, so wait out the boundary first
const waitUntilSafelyInsideTotpStep = async (): Promise<void> => {
const millisecondsRemainingInStep =
TOTP_STEP_DURATION_MS - (Date.now() % TOTP_STEP_DURATION_MS);
if (millisecondsRemainingInStep < MINIMUM_STEP_REMAINING_MS) {
await new Promise((resolve) =>
setTimeout(resolve, millisecondsRemainingInStep + 100),
);
}
};
const deleteJonyTwoFactorAuthenticationMethods = async (): Promise<void> => {
await global.testDataSource.query(
`DELETE FROM core."twoFactorAuthenticationMethod" WHERE "userWorkspaceId" = $1`,
[USER_WORKSPACE_DATA_SEED_IDS.JONY],
);
};
describe('Two-factor authentication TOTP verification (integration)', () => {
let secret: string;
beforeAll(async () => {
await deleteJonyTwoFactorAuthenticationMethods();
const { data, errors } = await initiateOtpProvisioningForAuthenticatedUser({
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
expectToFail: false,
});
expect(errors).toBeUndefined();
const uri = data.initiateOTPProvisioningForAuthenticatedUser.uri;
expect(uri).toMatch(/^otpauth:\/\/totp\//);
const secretFromUri = uri.match(/[?&]secret=([^&]+)/)?.[1];
if (secretFromUri === undefined || secretFromUri === '') {
throw new Error('Expected the otpauth URI to contain a secret');
}
secret = secretFromUri;
});
afterAll(async () => {
await deleteJonyTwoFactorAuthenticationMethods();
});
it('should reject a code that is more than one step old', async () => {
const staleToken = generateTokenAtEpoch(
secret,
Date.now() - 2 * TOTP_STEP_DURATION_MS,
);
const { data, errors } = await verifyTwoFactorAuthenticationMethod({
otp: staleToken,
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
expectToFail: true,
});
expect(errors).toBeDefined();
expect(
data?.verifyTwoFactorAuthenticationMethodForAuthenticatedUser,
).toBeFalsy();
});
it('should accept the current code', async () => {
await waitUntilSafelyInsideTotpStep();
const currentToken = generateTokenAtEpoch(secret, Date.now());
const { data, errors } = await verifyTwoFactorAuthenticationMethod({
otp: currentToken,
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(
data.verifyTwoFactorAuthenticationMethodForAuthenticatedUser.success,
).toBe(true);
});
it('should accept the code from the previous step', async () => {
await waitUntilSafelyInsideTotpStep();
const previousStepToken = generateTokenAtEpoch(
secret,
Date.now() - TOTP_STEP_DURATION_MS,
);
const { data, errors } = await verifyTwoFactorAuthenticationMethod({
otp: previousStepToken,
accessToken: APPLE_JONY_MEMBER_ACCESS_TOKEN,
expectToFail: false,
});
expect(errors).toBeUndefined();
expect(
data.verifyTwoFactorAuthenticationMethodForAuthenticatedUser.success,
).toBe(true);
});
});

View File

@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`successful session cookie delivery on a secure deployment (integration) should deliver the host-locked secure cookie variant: secure-session-cookie 1`] = `"__Host-twenty-session=sess_<redacted>; Path=/; Expires=<redacted>; HttpOnly; Secure; SameSite=None"`;

View File

@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`successful user session creation on auth exchanges (integration) should mint a session and set an httpOnly cookie on sign-in from an allowed origin: session-cookie 1`] = `"twenty-session=sess_<redacted>; Path=/; Expires=<redacted>; HttpOnly; SameSite=Lax"`;

View File

@@ -0,0 +1,6 @@
// The secure/insecure cookie branch is decided by configuration, never by the
// transport: isSecureDeployment() reads SERVER_URL, which is env-only and so
// cannot be flipped at runtime the way the SameSite=None side door can.
export const IS_SECURE_DEPLOYMENT = (process.env.SERVER_URL ?? '').startsWith(
'https://',
);

View File

@@ -0,0 +1,21 @@
import { isNonEmptyString } from '@sniptt/guards';
// The cookie-issuance gate and the CSRF middleware allowlist FRONTEND_URL
// (resolveAllowedCredentialedOrigins), which .env.test sets to
// http://localhost:3001. Deriving it here keeps the suite honest if the test
// environment ever moves, and normalizing to URL.origin keeps the header
// aligned with the server-side comparison when FRONTEND_URL carries a path or
// trailing slash.
const resolveAllowedOrigin = (): string => {
const frontendUrl = process.env.FRONTEND_URL;
if (!isNonEmptyString(frontendUrl)) {
return 'http://localhost:3001';
}
return new URL(frontendUrl).origin;
};
export const ALLOWED_ORIGIN = resolveAllowedOrigin();
export const DISALLOWED_ORIGIN = 'https://attacker.example.com';

View File

@@ -0,0 +1,57 @@
import request from 'supertest';
import {
ALLOWED_ORIGIN,
DISALLOWED_ORIGIN,
} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
const SERVER_URL = `http://localhost:${APP_PORT}`;
// The browser-side half of cross-origin cookie support, sibling of the CSRF
// middleware: allowlisted origins get credentialed CORS (reflected origin plus
// allow-credentials), everything else keeps the public wildcard. Runs
// unconditionally: the CORS setup does not depend on the cookie-sessions flag.
describe('credentialed CORS origins (integration)', () => {
it('should reflect an allowlisted origin with credentials and Vary: Origin', async () => {
const response = await request(SERVER_URL)
.get('/client-config')
.set('Origin', ALLOWED_ORIGIN)
.expect(200);
expect(response.headers['access-control-allow-origin']).toBe(
ALLOWED_ORIGIN,
);
expect(response.headers['access-control-allow-credentials']).toBe('true');
// Without Vary: Origin a shared cache could serve the wildcard response
// to an allowlisted origin, whose credentialed request the browser would
// then reject.
expect(response.headers.vary).toContain('Origin');
});
it('should answer a preflight for an allowlisted origin with credentials', async () => {
const response = await request(SERVER_URL)
.options('/metadata')
.set('Origin', ALLOWED_ORIGIN)
.set('Access-Control-Request-Method', 'POST')
.set('Access-Control-Request-Headers', 'content-type');
expect(response.status).toBeLessThan(300);
expect(response.headers['access-control-allow-origin']).toBe(
ALLOWED_ORIGIN,
);
expect(response.headers['access-control-allow-credentials']).toBe('true');
// Preflights are cacheable, so this is the response a shared cache is most
// likely to reuse across origins.
expect(response.headers.vary).toContain('Origin');
});
it('should fall back to the public wildcard for a non-allowlisted origin', async () => {
const response = await request(SERVER_URL)
.get('/client-config')
.set('Origin', DISALLOWED_ORIGIN)
.expect(200);
expect(response.headers['access-control-allow-origin']).toBe('*');
expect(response.headers.vary).toContain('Origin');
});
});

View File

@@ -0,0 +1,87 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import {
extractSessionCookie,
hasClearingCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { currentUserIdentityQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { generateUserSessionToken } from 'src/engine/core-modules/user-session/utils/generate-user-session-token.util';
import {
ALLOWED_ORIGIN,
DISALLOWED_ORIGIN,
} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
describe('failing session cookie authentication (integration)', () => {
let sessionToken: string;
let sessionCookieName: string;
let sessionCookieHeader: string;
beforeAll(async () => {
const signInResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(signInResponse);
if (!sessionCookie) {
throw new Error('Expected a session cookie from sign-in');
}
sessionToken = sessionCookie.sessionToken;
sessionCookieName = sessionCookie.cookieName;
sessionCookieHeader = sessionCookie.cookieHeader;
});
it('should reject a session token presented as a Bearer header', async () => {
// Cookie-only by design: accepting sess_ tokens as Bearer would reopen
// the XSS-exfiltration surface cookie sessions close.
const response = await makeMetadataAPIRequest(
currentUserIdentityQueryFactory(),
sessionToken,
).expect(200);
expect(response.body.data).toBeUndefined();
expect(response.body.errors).toBeDefined();
});
it('should return 403 on a cookie-authenticated request from a disallowed origin', async () => {
const response = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{
originHeader: DISALLOWED_ORIGIN,
cookieHeader: sessionCookieHeader,
},
403,
);
expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH');
});
it('should return 403 on a cookie-authenticated request without an Origin header, failing closed', async () => {
const response = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{ cookieHeader: sessionCookieHeader },
403,
);
expect(response.body.error).toBe('CSRF_ORIGIN_MISMATCH');
});
it('should treat an unknown session token as unauthenticated and clear the dead cookie', async () => {
const unknownSessionToken = generateUserSessionToken();
const response = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: `${sessionCookieName}=${unknownSessionToken}`,
},
);
expect(response.body.errors).toBeDefined();
expect(hasClearingCookie(response)).toBe(true);
});
});

View File

@@ -0,0 +1,129 @@
import * as jwt from 'jsonwebtoken';
import { buildAppleWorkspaceOrigin } from 'test/integration/graphql/utils/build-apple-workspace-origin.util';
import { forgeLegacyHs256Token } from 'test/integration/graphql/utils/forge-legacy-hs256-token.util';
import { getAuthTokensFromLoginTokenQueryFactory } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.query-factory.util';
import { getLoginTokenFromCredentialsQueryFactory } from 'test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
hasClearingCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { type LoginTokenJwtPayload } from 'src/engine/core-modules/auth/types/login-token-jwt-payload.type';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import {
ALLOWED_ORIGIN,
DISALLOWED_ORIGIN,
} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
describe('failing user session creation on auth exchanges (integration)', () => {
it('should fail the exchange when a disallowed origin cannot receive its required session cookie', async () => {
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
const sessionCountBefore = await userSessionRepository.count();
const response = await signInWithCookieCapture({
originHeader: DISALLOWED_ORIGIN,
});
expect(response.body.errors).toBeDefined();
expect(response.body.data?.getAuthTokensFromLoginToken).toBeFalsy();
expect(extractSessionCookie(response)).toBeUndefined();
const sessionCountAfter = await userSessionRepository.count();
expect(sessionCountAfter).toBe(sessionCountBefore);
});
it('should preserve the presented session when replacement session creation fails', async () => {
const existingSessionResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const existingSessionCookie = extractSessionCookie(existingSessionResponse);
if (!existingSessionCookie) {
throw new Error('Expected an existing session cookie');
}
const workspaceOrigin = buildAppleWorkspaceOrigin();
const loginTokenResponse = await postMetadataOperationWithHeaders(
getLoginTokenFromCredentialsQueryFactory({
email: 'tim@apple.dev',
password: 'tim@apple.dev',
origin: workspaceOrigin,
}),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: existingSessionCookie.cookieHeader,
},
);
const validLoginToken =
loginTokenResponse.body.data.getLoginTokenFromCredentials.loginToken
.token;
const validPayload = jwt.decode(validLoginToken) as LoginTokenJwtPayload;
const loginTokenWithoutProvider = forgeLegacyHs256Token(
{
sub: validPayload.sub,
type: validPayload.type,
workspaceId: validPayload.workspaceId,
},
validPayload.workspaceId,
);
const failedExchangeResponse = await postMetadataOperationWithHeaders(
getAuthTokensFromLoginTokenQueryFactory({
loginToken: loginTokenWithoutProvider,
origin: workspaceOrigin,
}),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: existingSessionCookie.cookieHeader,
},
);
expect(failedExchangeResponse.body.errors).toBeDefined();
expect(
failedExchangeResponse.body.data?.getAuthTokensFromLoginToken,
).toBeFalsy();
expect(extractSessionCookie(failedExchangeResponse)).toBeUndefined();
expect(hasClearingCookie(failedExchangeResponse)).toBe(false);
const existingSession = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({
tokenHash: hashUserSessionToken(existingSessionCookie.sessionToken),
});
expect(existingSession?.revokedAt).toBeNull();
});
it('should mint nothing when the credentials exchange itself fails', async () => {
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
const sessionCountBefore = await userSessionRepository.count();
const response = await makeMetadataAPIRequest(
getLoginTokenFromCredentialsQueryFactory({
email: 'tim@apple.dev',
password: 'wrong-password',
origin: buildAppleWorkspaceOrigin(),
}),
null,
)
.set('Origin', ALLOWED_ORIGIN)
.expect(200);
expect(response.body.errors).toBeDefined();
expect(extractSessionCookie(response)).toBeUndefined();
const sessionCountAfter = await userSessionRepository.count();
expect(sessionCountAfter).toBe(sessionCountBefore);
});
});

View File

@@ -0,0 +1,73 @@
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
hasClearingCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { currentUserIdentityQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
// Sessions created here are never resolved before the row is tampered with,
// so the read-through cache holds no entry and the checks hit the database.
describe('failing session expiration (integration)', () => {
const signInAndTamper = async (
tamper: Partial<Pick<UserSessionEntity, 'expiresAt' | 'lastActiveAt'>>,
): Promise<string> => {
const signInResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(signInResponse);
if (!sessionCookie) {
throw new Error('Expected a session cookie from sign-in');
}
await getCoreRepository<UserSessionEntity>(UserSessionEntity).update(
{ tokenHash: hashUserSessionToken(sessionCookie.sessionToken) },
tamper,
);
return sessionCookie.cookieHeader;
};
const expectCookieRejected = async (
sessionCookieHeader: string,
): Promise<void> => {
const response = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: sessionCookieHeader,
},
);
expect(response.body.errors).toBeDefined();
expect(hasClearingCookie(response)).toBe(true);
};
it('should reject a session past its absolute lifetime and clear the cookie', async () => {
const sessionCookieHeader = await signInAndTamper({
expiresAt: new Date(Date.now() - ONE_DAY_MS),
});
await expectCookieRejected(sessionCookieHeader);
});
it('should reject a session past the idle timeout and clear the cookie', async () => {
// 31 days idle exceeds the 30d SESSION_IDLE_TIMEOUT while the absolute
// 180d lifetime is still far in the future.
const sessionCookieHeader = await signInAndTamper({
lastActiveAt: new Date(Date.now() - 31 * ONE_DAY_MS),
});
await expectCookieRejected(sessionCookieHeader);
});
});

View File

@@ -0,0 +1,66 @@
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import {
currentUserSessionsQueryFactory,
revokeUserSessionQueryFactory,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
describe('failing user sessions API (integration)', () => {
let timSessionId: string;
beforeAll(async () => {
const signInResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(signInResponse);
if (!sessionCookie) {
throw new Error('Expected a session cookie from sign-in');
}
const sessionRow = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({ tokenHash: hashUserSessionToken(sessionCookie.sessionToken) });
if (!sessionRow) {
throw new Error('Expected a persisted session row');
}
timSessionId = sessionRow.id;
});
it('should reject an unauthenticated sessions listing', async () => {
const response = await postMetadataOperationWithHeaders(
currentUserSessionsQueryFactory(),
{ originHeader: ALLOWED_ORIGIN },
);
expect(response.body.errors).toBeDefined();
});
it("should refuse to revoke another user's session", async () => {
// Default token authenticates Jane, a different seeded user than Tim.
const response = await makeMetadataAPIRequest(
revokeUserSessionQueryFactory({ userSessionId: timSessionId }),
).expect(200);
expect(response.body.errors).toBeDefined();
const sessionRow = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({ id: timSessionId });
expect(sessionRow?.revokedAt).toBeNull();
});
});

View File

@@ -0,0 +1,45 @@
import {
extractSessionCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { currentUserIdentityQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
describe('successful session cookie authentication (integration)', () => {
let sessionCookieHeader: string;
beforeAll(async () => {
const signInResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(signInResponse);
if (!sessionCookie) {
throw new Error('Expected a session cookie from sign-in');
}
sessionCookieHeader = sessionCookie.cookieHeader;
});
it('should authenticate a request carrying only the session cookie', async () => {
const response = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: sessionCookieHeader,
},
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.currentUser).toMatchObject({
id: USER_DATA_SEED_IDS.TIM,
email: 'tim@apple.dev',
});
});
});

View File

@@ -0,0 +1,49 @@
import {
extractSessionCookie,
normalizeSessionCookieForSnapshot,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant';
import { USER_SESSION_SECURE_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
import { setupDatabaseConfigOverrideForSuite } from 'test/integration/graphql/suites/auth/user-sessions/utils/setup-database-config-override.util';
// SameSite=None forces Secure (browsers reject the combination without it),
// which is the one secure-deployment trigger reachable at runtime: SERVER_URL
// stays plain http in .env.test. The production combination (https SERVER_URL
// with the SameSite=Lax default) is covered by
// secure-deployment-session-cookie.integration-spec.ts under a dedicated app
// boot.
describe('successful session cookie delivery on a secure deployment (integration)', () => {
setupDatabaseConfigOverrideForSuite('AUTH_COOKIE_SAME_SITE', 'none');
it('should deliver the host-locked secure cookie variant', async () => {
const response = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
expect(
extractSessionCookie(response, USER_SESSION_COOKIE_NAME),
).toBeUndefined();
const secureSessionCookie = extractSessionCookie(
response,
USER_SESSION_SECURE_COOKIE_NAME,
);
expect(secureSessionCookie).toBeDefined();
if (!secureSessionCookie) {
throw new Error('Expected a secure session cookie');
}
// __Host- requires Secure, Path=/ and no Domain; browsers enforce the
// prefix contract, so the snapshot pins host-only scoping.
expect(
normalizeSessionCookieForSnapshot(secureSessionCookie.rawCookie),
).toMatchSnapshot('secure-session-cookie');
});
});

View File

@@ -0,0 +1,163 @@
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
normalizeSessionCookieForSnapshot,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
import { IS_SECURE_DEPLOYMENT } from 'test/integration/graphql/suites/auth/user-sessions/constants/is-secure-deployment.constant';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const SESSION_ABSOLUTE_LIFETIME_MS = 180 * ONE_DAY_MS;
describe('successful user session creation on auth exchanges (integration)', () => {
let firstSessionToken: string;
let firstSessionCookieHeader: string;
it('should mint a session and set an httpOnly cookie on sign-in from an allowed origin', async () => {
const response = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
expect(
response.body.data.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token,
).toBeDefined();
const sessionCookie = extractSessionCookie(response);
expect(sessionCookie).toBeDefined();
if (!sessionCookie) {
throw new Error('Expected a session cookie');
}
firstSessionToken = sessionCookie.sessionToken;
firstSessionCookieHeader = sessionCookie.cookieHeader;
// Pins name, sess_ prefix, attribute list and order in one place. The
// absences matter as much as the presences: no Secure and no __Host-
// (plain-http test deployment), and no Domain, which is what makes the
// cookie host-only so browsers never send it to sibling workspace
// subdomains. The secure boot pins its own shape in
// secure-deployment-session-cookie.integration-spec.ts, so this snapshot
// only applies to the insecure one.
if (!IS_SECURE_DEPLOYMENT) {
expect(
normalizeSessionCookieForSnapshot(sessionCookie.rawCookie),
).toMatchSnapshot('session-cookie');
}
const expiresAttribute = sessionCookie.rawCookie
.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith('Expires='));
expect(expiresAttribute).toBeDefined();
const cookieLifetimeMs =
new Date(expiresAttribute!.slice('Expires='.length)).getTime() -
Date.now();
expect(cookieLifetimeMs).toBeGreaterThan(
SESSION_ABSOLUTE_LIFETIME_MS - ONE_DAY_MS,
);
expect(cookieLifetimeMs).toBeLessThan(
SESSION_ABSOLUTE_LIFETIME_MS + ONE_DAY_MS,
);
});
it('should store only the token hash at rest, with the expected session shape', async () => {
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
const storedByRawToken = await userSessionRepository.findOneBy({
tokenHash: firstSessionToken,
});
expect(storedByRawToken).toBeNull();
const session = await userSessionRepository.findOneBy({
tokenHash: hashUserSessionToken(firstSessionToken),
});
expect(session).not.toBeNull();
if (!session) {
throw new Error('Expected a persisted session');
}
expect(session).toMatchObject({
userId: expect.any(String),
// Bound to the workspace the GraphQL origin selected: the server-side
// half of workspace scoping, alongside the host-only cookie.
workspaceId: SEED_APPLE_WORKSPACE_ID,
userWorkspaceId: expect.any(String),
isImpersonating: false,
revokedAt: null,
revokedReason: null,
// expect.any(Date) would fail: the entity's Date comes from the app's
// vm context, so it is not an instanceof the test context's Date.
lastActiveAt: expect.anything(),
});
const sessionLifetimeMs =
session.expiresAt.getTime() - session.createdAt.getTime();
expect(sessionLifetimeMs).toBeGreaterThan(
SESSION_ABSOLUTE_LIFETIME_MS - ONE_DAY_MS,
);
expect(sessionLifetimeMs).toBeLessThan(
SESSION_ABSOLUTE_LIFETIME_MS + ONE_DAY_MS,
);
});
it('should set the cookie on a sign-in without an Origin header, keeping scripted sign-ins working', async () => {
const response = await signInWithCookieCapture();
expect(
response.body.data.getAuthTokensFromLoginToken.tokens
.accessOrWorkspaceAgnosticToken.token,
).toBeDefined();
expect(extractSessionCookie(response)).toBeDefined();
});
it('should revoke the presented session as superseded when signing in over it', async () => {
const response = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
cookieHeader: firstSessionCookieHeader,
});
const newSessionCookie = extractSessionCookie(response);
expect(newSessionCookie).toBeDefined();
expect(newSessionCookie?.sessionToken).not.toBe(firstSessionToken);
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
const supersededSession = await userSessionRepository.findOneBy({
tokenHash: hashUserSessionToken(firstSessionToken),
});
expect(supersededSession).not.toBeNull();
expect(supersededSession?.revokedAt).not.toBeNull();
expect(supersededSession?.revokedReason).toBe(
UserSessionRevokedReason.Superseded,
);
const newSession = await userSessionRepository.findOneBy({
tokenHash: hashUserSessionToken(newSessionCookie?.sessionToken as string),
});
expect(newSession).not.toBeNull();
expect(newSession?.revokedAt).toBeNull();
});
});

View File

@@ -0,0 +1,104 @@
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import { currentUserWorkspaceContextQueryFactory } from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
} from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
// Tim is seeded in both the apple and yc workspaces, which is what makes this
// provable: same user, same credentials, two sessions, and each cookie can
// only ever reach the workspace its exchange selected. No request-side input
// (header, variable, origin) lets a session pivot to another workspace; the
// context is rebuilt from the session row alone.
describe('successful session workspace binding (integration)', () => {
let appleSessionToken: string;
let ycSessionToken: string;
let appleSessionCookieHeader: string;
let ycSessionCookieHeader: string;
const fetchWorkspaceContext = async (sessionCookieHeader: string) => {
const response = await postMetadataOperationWithHeaders(
currentUserWorkspaceContextQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: sessionCookieHeader,
},
);
expect(response.body.errors).toBeUndefined();
return response.body.data.currentUser;
};
beforeAll(async () => {
const appleResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const ycResponse = await signInWithCookieCapture({
workspaceSubdomain: 'yc',
originHeader: ALLOWED_ORIGIN,
});
const appleCookie = extractSessionCookie(appleResponse);
const ycCookie = extractSessionCookie(ycResponse);
if (!appleCookie || !ycCookie) {
throw new Error('Expected session cookies from both workspace sign-ins');
}
appleSessionToken = appleCookie.sessionToken;
ycSessionToken = ycCookie.sessionToken;
appleSessionCookieHeader = appleCookie.cookieHeader;
ycSessionCookieHeader = ycCookie.cookieHeader;
});
it('should persist each session bound to the workspace its exchange selected', async () => {
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
const appleSession = await userSessionRepository.findOneBy({
tokenHash: hashUserSessionToken(appleSessionToken),
});
const ycSession = await userSessionRepository.findOneBy({
tokenHash: hashUserSessionToken(ycSessionToken),
});
expect(appleSession?.workspaceId).toBe(SEED_APPLE_WORKSPACE_ID);
expect(appleSession?.userWorkspaceId).toBe(
USER_WORKSPACE_DATA_SEED_IDS.TIM,
);
expect(ycSession?.workspaceId).toBe(SEED_YCOMBINATOR_WORKSPACE_ID);
expect(ycSession?.userWorkspaceId).toBe(
USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME,
);
});
it('should resolve the auth context of each cookie to its own workspace, with no cross-workspace pivot', async () => {
const appleContext = await fetchWorkspaceContext(appleSessionCookieHeader);
const ycContext = await fetchWorkspaceContext(ycSessionCookieHeader);
expect(appleContext.email).toBe('tim@apple.dev');
expect(appleContext.currentWorkspace.id).toBe(SEED_APPLE_WORKSPACE_ID);
expect(appleContext.currentUserWorkspace.id).toBe(
USER_WORKSPACE_DATA_SEED_IDS.TIM,
);
expect(ycContext.email).toBe('tim@apple.dev');
expect(ycContext.currentWorkspace.id).toBe(SEED_YCOMBINATOR_WORKSPACE_ID);
expect(ycContext.currentUserWorkspace.id).toBe(
USER_WORKSPACE_DATA_SEED_IDS.TIM_ACME,
);
});
});

View File

@@ -0,0 +1,85 @@
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
hasClearingCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import {
currentUserIdentityQueryFactory,
signOutQueryFactory,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import {
ALLOWED_ORIGIN,
DISALLOWED_ORIGIN,
} from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
describe('successful sign-out (integration)', () => {
it('should revoke the presented session, clear the cookie, and reject its reuse', async () => {
const signInResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(signInResponse);
if (!sessionCookie) {
throw new Error('Expected a session cookie from sign-in');
}
const signOutResponse = await postMetadataOperationWithHeaders(
signOutQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: sessionCookie.cookieHeader,
},
);
expect(signOutResponse.body.errors).toBeUndefined();
expect(signOutResponse.body.data.signOut).toBe(true);
expect(hasClearingCookie(signOutResponse)).toBe(true);
const revokedRow = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({
tokenHash: hashUserSessionToken(sessionCookie.sessionToken),
});
expect(revokedRow?.revokedAt).not.toBeNull();
expect(revokedRow?.revokedReason).toBe(
UserSessionRevokedReason.UserSignOut,
);
// Revocation invalidates the cache, so reuse fails immediately, not
// after the cache TTL.
const reuseResponse = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: sessionCookie.cookieHeader,
},
);
expect(reuseResponse.body.errors).toBeDefined();
expect(hasClearingCookie(reuseResponse)).toBe(true);
});
it('should not clear anything on a cookie-less sign-out, so a cross-site POST cannot log a visitor out', async () => {
// What a cross-site forgery actually looks like server-side: SameSite=Lax
// keeps the cookie off the request, and the attacker page's origin comes
// along. CSRF does not apply (no cookie), so the mutation runs and must
// still clear nothing.
const response = await postMetadataOperationWithHeaders(
signOutQueryFactory(),
{ originHeader: DISALLOWED_ORIGIN },
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.signOut).toBe(true);
expect(hasClearingCookie(response)).toBe(false);
});
});

View File

@@ -0,0 +1,134 @@
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
extractSessionCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import {
currentUserSessionsQueryFactory,
revokeAllOtherUserSessionsQueryFactory,
revokeUserSessionQueryFactory,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
type UserSessionApiEntry = {
id: string;
isCurrent: boolean;
isImpersonating: boolean;
authProvider: string;
};
describe('successful user sessions API (integration)', () => {
let currentSessionCookieHeader: string;
let otherSessionToken: string;
const fetchSessions = async (): Promise<UserSessionApiEntry[]> => {
const response = await postMetadataOperationWithHeaders(
currentUserSessionsQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: currentSessionCookieHeader,
},
);
expect(response.body.errors).toBeUndefined();
return response.body.data.currentUserSessions;
};
beforeAll(async () => {
// Two devices: the "other" one signs in first so the second sign-in does
// not supersede it (no cookie presented on either exchange).
const otherDeviceResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const currentDeviceResponse = await signInWithCookieCapture({
originHeader: ALLOWED_ORIGIN,
});
const otherCookie = extractSessionCookie(otherDeviceResponse);
const currentCookie = extractSessionCookie(currentDeviceResponse);
if (!otherCookie || !currentCookie) {
throw new Error('Expected session cookies from both sign-ins');
}
otherSessionToken = otherCookie.sessionToken;
currentSessionCookieHeader = currentCookie.cookieHeader;
});
it('should list active sessions and mark only the presented one as current', async () => {
const sessions = await fetchSessions();
expect(sessions.length).toBeGreaterThanOrEqual(2);
const currentSessions = sessions.filter((session) => session.isCurrent);
expect(currentSessions).toHaveLength(1);
expect(currentSessions[0].authProvider).toBe('password');
expect(currentSessions[0].isImpersonating).toBe(false);
});
it('should revoke a targeted session by id', async () => {
const otherSessionRow = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({ tokenHash: hashUserSessionToken(otherSessionToken) });
if (!otherSessionRow) {
throw new Error('Expected the other session row');
}
const response = await postMetadataOperationWithHeaders(
revokeUserSessionQueryFactory({ userSessionId: otherSessionRow.id }),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: currentSessionCookieHeader,
},
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.revokeUserSession).toBe(true);
const revokedRow = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findOneBy({ id: otherSessionRow.id });
expect(revokedRow?.revokedAt).not.toBeNull();
expect(revokedRow?.revokedReason).toBe(UserSessionRevokedReason.UserRevoked);
const sessions = await fetchSessions();
expect(
sessions.find((session) => session.id === otherSessionRow.id),
).toBeUndefined();
});
it('should revoke every other session but keep the presented one alive', async () => {
// A fresh sign-in guarantees at least one other active session to revoke.
await signInWithCookieCapture({ originHeader: ALLOWED_ORIGIN });
const response = await postMetadataOperationWithHeaders(
revokeAllOtherUserSessionsQueryFactory(),
{
originHeader: ALLOWED_ORIGIN,
cookieHeader: currentSessionCookieHeader,
},
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.revokeAllOtherUserSessions).toBeGreaterThanOrEqual(
1,
);
const sessions = await fetchSessions();
expect(sessions).toHaveLength(1);
expect(sessions[0].isCurrent).toBe(true);
});
});

View File

@@ -0,0 +1,251 @@
import { In } from 'typeorm';
import { getAppProviderByClassName } from 'test/integration/utils/get-app-provider-by-class-name.util';
import { getCoreRepository } from 'test/integration/utils/get-core-repository.util';
import {
AppTokenEntity,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { UserSessionEntity } from 'src/engine/core-modules/user-session/user-session.entity';
import { UserSessionRevokedReason } from 'src/engine/core-modules/user-session/types/user-session-revoked-reason.type';
import { generateUserSessionToken } from 'src/engine/core-modules/user-session/utils/generate-user-session-token.util';
import { hashUserSessionToken } from 'src/engine/core-modules/user-session/utils/hash-user-session-token.util';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { USER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-users.util';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const daysAgo = (days: number): Date => new Date(Date.now() - days * ONE_DAY_MS);
const daysFromNow = (days: number): Date =>
new Date(Date.now() + days * ONE_DAY_MS);
type SeededSessionFixture = {
label: string;
tokenHash: string;
overrides: Partial<UserSessionEntity>;
};
// The retention boundary is 30 days after a session ended, where "ended"
// means absolute expiry or revocation. The cron runs against the same table
// the suite's other specs write to, so fixtures carry their own token hashes
// and are matched individually rather than by table counts.
describe('user session cleanup cron (integration)', () => {
const fixtures: SeededSessionFixture[] = [
{
label: 'expired beyond retention',
tokenHash: hashUserSessionToken(generateUserSessionToken()),
overrides: { expiresAt: daysAgo(31), lastActiveAt: daysAgo(31) },
},
{
label: 'revoked beyond retention',
tokenHash: hashUserSessionToken(generateUserSessionToken()),
overrides: {
expiresAt: daysFromNow(90),
lastActiveAt: daysAgo(31),
revokedAt: daysAgo(31),
revokedReason: UserSessionRevokedReason.UserSignOut,
},
},
{
label: 'active',
tokenHash: hashUserSessionToken(generateUserSessionToken()),
overrides: { expiresAt: daysFromNow(90), lastActiveAt: new Date() },
},
{
label: 'expired within retention',
tokenHash: hashUserSessionToken(generateUserSessionToken()),
overrides: { expiresAt: daysAgo(1), lastActiveAt: daysAgo(1) },
},
{
// Idle-expired sessions are unusable but carry no ended marker, so the
// current predicate retains them until absolute expiry. Pinned here as
// documented behavior; tightening the predicate should flip this case.
label: 'idle-expired but not absolutely expired',
tokenHash: hashUserSessionToken(generateUserSessionToken()),
overrides: { expiresAt: daysFromNow(60), lastActiveAt: daysAgo(120) },
},
];
const allFixtureHashes = fixtures.map((fixture) => fixture.tokenHash);
const findRemainingFixtureHashes = async (): Promise<string[]> => {
const rows = await getCoreRepository<UserSessionEntity>(
UserSessionEntity,
).findBy({ tokenHash: In(allFixtureHashes) });
return rows.map((row) => row.tokenHash);
};
beforeAll(async () => {
const userSessionRepository =
getCoreRepository<UserSessionEntity>(UserSessionEntity);
for (const fixture of fixtures) {
await userSessionRepository.save(
userSessionRepository.create({
tokenHash: fixture.tokenHash,
userId: USER_DATA_SEED_IDS.TIM,
workspaceId: null,
userWorkspaceId: null,
authProvider: AuthProviderEnum.Password,
...fixture.overrides,
}),
);
}
});
afterAll(async () => {
await getCoreRepository<UserSessionEntity>(UserSessionEntity).delete({
tokenHash: In(allFixtureHashes),
});
});
it('should delete sessions ended beyond retention and keep the rest', async () => {
const cleanupJob = getAppProviderByClassName<{
handle: () => Promise<void>;
}>('UserSessionCleanupCronJob');
await cleanupJob.handle();
const remainingHashes = await findRemainingFixtureHashes();
// Strict lookup: a mistyped label must fail the test here, not slip
// through as not.toContain(undefined).
const getFixtureTokenHash = (label: string): string => {
const fixture = fixtures.find((candidate) => candidate.label === label);
if (!fixture) {
throw new Error(`Unknown user session fixture: ${label}`);
}
return fixture.tokenHash;
};
const expectDeleted = (label: string) => {
expect(remainingHashes).not.toContain(getFixtureTokenHash(label));
};
const expectKept = (label: string) => {
expect(remainingHashes).toContain(getFixtureTokenHash(label));
};
expectDeleted('expired beyond retention');
expectDeleted('revoked beyond retention');
expectKept('active');
expectKept('expired within retention');
expectKept('idle-expired but not absolutely expired');
});
describe('refresh-token half', () => {
type SeededAppTokenFixture = {
label: string;
type: AppTokenType;
overrides: Partial<Pick<AppTokenEntity, 'expiresAt' | 'revokedAt'>>;
};
const appTokenFixtures: SeededAppTokenFixture[] = [
{
label: 'refresh token expired beyond retention',
type: AppTokenType.RefreshToken,
overrides: { expiresAt: daysAgo(31) },
},
{
label: 'refresh token revoked beyond retention',
type: AppTokenType.RefreshToken,
overrides: { expiresAt: daysFromNow(30), revokedAt: daysAgo(31) },
},
{
label: 'active refresh token',
type: AppTokenType.RefreshToken,
overrides: { expiresAt: daysFromNow(30) },
},
// The retention boundary itself: without these two, a regression that
// deleted every ended refresh token rather than only those past
// retention would still pass the assertions above.
{
label: 'refresh token expired within retention',
type: AppTokenType.RefreshToken,
overrides: { expiresAt: daysAgo(1) },
},
{
label: 'refresh token revoked within retention',
type: AppTokenType.RefreshToken,
overrides: { expiresAt: daysFromNow(30), revokedAt: daysAgo(1) },
},
{
// The type filter is the safety predicate: appToken is a shared table
// and other token types are routinely long-expired. Losing the filter
// would silently hard-delete password-reset or invitation history.
label: 'long-expired token of another type',
type: AppTokenType.PasswordResetToken,
overrides: { expiresAt: daysAgo(31) },
},
];
const seededAppTokenIds = new Map<string, string>();
beforeAll(async () => {
const appTokenRepository =
getCoreRepository<AppTokenEntity>(AppTokenEntity);
for (const fixture of appTokenFixtures) {
const saved = await appTokenRepository.save(
appTokenRepository.create({
userId: USER_DATA_SEED_IDS.TIM,
type: fixture.type,
value: '',
...fixture.overrides,
}),
);
seededAppTokenIds.set(fixture.label, saved.id);
}
});
afterAll(async () => {
await getCoreRepository<AppTokenEntity>(AppTokenEntity).delete({
id: In([...seededAppTokenIds.values()]),
});
});
it('should delete only refresh tokens ended beyond retention', async () => {
const cleanupJob = getAppProviderByClassName<{
handle: () => Promise<void>;
}>('UserSessionCleanupCronJob');
await cleanupJob.handle();
const remainingRows = await getCoreRepository<AppTokenEntity>(
AppTokenEntity,
).findBy({ id: In([...seededAppTokenIds.values()]) });
const remainingIds = remainingRows.map((row) => row.id);
const getSeededAppTokenId = (label: string): string => {
const id = seededAppTokenIds.get(label);
if (id === undefined) {
throw new Error(`Unknown app token fixture: ${label}`);
}
return id;
};
expect(remainingIds).not.toContain(
getSeededAppTokenId('refresh token expired beyond retention'),
);
expect(remainingIds).not.toContain(
getSeededAppTokenId('refresh token revoked beyond retention'),
);
expect(remainingIds).toContain(getSeededAppTokenId('active refresh token'));
expect(remainingIds).toContain(
getSeededAppTokenId('refresh token expired within retention'),
);
expect(remainingIds).toContain(
getSeededAppTokenId('refresh token revoked within retention'),
);
expect(remainingIds).toContain(
getSeededAppTokenId('long-expired token of another type'),
);
});
});
});

View File

@@ -0,0 +1,53 @@
import { type ConfigVariableValue } from 'twenty-shared/types';
import { createConfigVariable } from 'test/integration/twenty-config/utils/create-config-variable.util';
import { deleteConfigVariable } from 'test/integration/twenty-config/utils/delete-config-variable.util';
import { getConfigVariable } from 'test/integration/twenty-config/utils/get-config-variable.util';
import { updateConfigVariable } from 'test/integration/twenty-config/utils/update-config-variable.util';
// Registers beforeAll/afterAll hooks that apply a database config override for
// the suite and restore the previous state afterwards, rather than blindly
// deleting: a suite must not erase an override another suite (or the
// environment) had in place.
export const setupDatabaseConfigOverrideForSuite = (
key: string,
value: ConfigVariableValue,
): void => {
let previousOverrideValue: ConfigVariableValue;
let hadPreviousOverride = false;
beforeAll(async () => {
const { data } = await getConfigVariable({ input: { key } });
hadPreviousOverride = data.getDatabaseConfigVariable.source === 'DATABASE';
if (hadPreviousOverride) {
previousOverrideValue = data.getDatabaseConfigVariable.value;
await updateConfigVariable({ input: { key, value } });
} else {
await createConfigVariable({ input: { key, value } });
}
});
afterAll(async () => {
try {
if (hadPreviousOverride) {
await updateConfigVariable({
input: { key, value: previousOverrideValue },
});
} else {
await deleteConfigVariable({ input: { key } });
}
} catch (error) {
// Reported rather than rethrown: a passing suite should not go red over
// its own teardown. But a swallowed failure leaves the override in place
// and the next suite fails for reasons that point nowhere near here, so
// it has to be visible.
process.stderr.write(
`[config-override] failed to restore ${key}, later suites may see a stale value: ${
error instanceof Error ? error.message : String(error)
}\n`,
);
}
});
};

View File

@@ -0,0 +1,152 @@
import { type Response } from 'supertest';
import { buildWorkspaceOriginForSubdomain } from 'test/integration/graphql/utils/build-apple-workspace-origin.util';
import { getAuthTokensFromLoginTokenQueryFactory } from 'test/integration/graphql/utils/get-auth-tokens-from-login-token.query-factory.util';
import { getLoginTokenFromCredentialsQueryFactory } from 'test/integration/graphql/utils/get-login-token-from-credentials.query-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { USER_SESSION_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-cookie-name.constant';
import { USER_SESSION_SECURE_COOKIE_NAME } from 'src/engine/core-modules/user-session/constants/user-session-secure-cookie-name.constant';
type RequestHeaders = {
originHeader?: string;
cookieHeader?: string;
};
// Supertest requests dispatch lazily, so Origin and Cookie can be set on the
// request makeMetadataAPIRequest already built. The explicit null token keeps
// these requests off Bearer authentication, which would bypass both the
// cookie auth path and the CSRF middleware; undefined would fall back to the
// util's default admin token.
export const postMetadataOperationWithHeaders = (
graphqlOperation: Parameters<typeof makeMetadataAPIRequest>[0],
{ originHeader, cookieHeader }: RequestHeaders,
expectedStatus = 200,
) => {
const graphqlRequest = makeMetadataAPIRequest(graphqlOperation, null);
if (originHeader !== undefined) {
graphqlRequest.set('Origin', originHeader);
}
if (cookieHeader !== undefined) {
graphqlRequest.set('Cookie', cookieHeader);
}
return graphqlRequest.expect(expectedStatus);
};
type SignInOptions = RequestHeaders & {
email?: string;
password?: string;
workspaceSubdomain?: string;
};
// Runs the full credentials exchange (credentials -> login token -> auth
// tokens) and returns the raw supertest response of the final exchange, so
// callers can assert on set-cookie headers, not just the GraphQL body.
export const signInWithCookieCapture = async ({
email = 'tim@apple.dev',
password = 'tim@apple.dev',
workspaceSubdomain = 'apple',
originHeader,
cookieHeader,
}: SignInOptions = {}): Promise<Response> => {
const workspaceOrigin = buildWorkspaceOriginForSubdomain(workspaceSubdomain);
const loginTokenResponse = await postMetadataOperationWithHeaders(
getLoginTokenFromCredentialsQueryFactory({
email,
password,
origin: workspaceOrigin,
}),
{ originHeader, cookieHeader },
);
const loginToken =
loginTokenResponse.body.data?.getLoginTokenFromCredentials?.loginToken
?.token;
expect(loginToken).toBeDefined();
return await postMetadataOperationWithHeaders(
getAuthTokensFromLoginTokenQueryFactory({
loginToken,
origin: workspaceOrigin,
}),
{ originHeader, cookieHeader },
);
};
// Redacts the two dynamic parts (token value, expiry timestamp) so the rest of
// the set-cookie header can be snapshot: name, attribute list and order are
// deterministic, and the snapshot also pins the absence of Domain and Secure.
export const normalizeSessionCookieForSnapshot = (rawCookie: string): string =>
rawCookie
.replace(/=sess_[A-Za-z0-9_-]+/, '=sess_<redacted>')
.replace(/Expires=[^;]+/, 'Expires=<redacted>');
export const getSetCookieHeaders = (response: Response): string[] =>
Array.isArray(response.headers['set-cookie'])
? response.headers['set-cookie']
: [];
// Which of the two names the server issues depends on SERVER_URL and
// AUTH_COOKIE_SAME_SITE, so callers match on both by default and read the name
// back off the response rather than assuming the insecure deployment. Specs
// that assert one specific variant pass the name explicitly.
const SESSION_COOKIE_NAMES = [
USER_SESSION_SECURE_COOKIE_NAME,
USER_SESSION_COOKIE_NAME,
];
// startsWith keeps the plain and __Host- names distinct: the prefixed name
// does not start with the plain one.
export const extractSessionCookie = (
response: Response,
cookieName?: string,
):
| { rawCookie: string; cookieName: string; cookieHeader: string; sessionToken: string }
| undefined => {
const candidateNames =
cookieName === undefined ? SESSION_COOKIE_NAMES : [cookieName];
for (const candidateName of candidateNames) {
const rawCookie = getSetCookieHeaders(response).find((cookie) =>
cookie.startsWith(`${candidateName}=sess_`),
);
if (rawCookie === undefined) {
continue;
}
const sessionToken = rawCookie
.split(';')[0]
.slice(`${candidateName}=`.length);
return {
rawCookie,
cookieName: candidateName,
cookieHeader: `${candidateName}=${sessionToken}`,
sessionToken,
};
}
return undefined;
};
// A deletion cookie is the name with an empty value and an epoch expiry; the
// extractor above will not match it because the value lacks the sess_ prefix.
export const hasClearingCookie = (
response: Response,
cookieName?: string,
): boolean => {
const candidateNames =
cookieName === undefined ? SESSION_COOKIE_NAMES : [cookieName];
return getSetCookieHeaders(response).some((cookie) =>
candidateNames.some(
(candidateName) =>
cookie.startsWith(`${candidateName}=;`) &&
cookie.includes('Expires=Thu, 01 Jan 1970'),
),
);
};

View File

@@ -0,0 +1,95 @@
import { gql } from 'graphql-tag';
export const currentUserIdentityQueryFactory = () => {
return {
query: gql`
query CurrentUser {
currentUser {
id
email
}
}
`,
variables: {},
};
};
export const currentUserWorkspaceContextQueryFactory = () => {
return {
query: gql`
query CurrentUserWorkspaceContext {
currentUser {
id
email
currentWorkspace {
id
}
currentUserWorkspace {
id
}
}
}
`,
variables: {},
};
};
export const currentUserSessionsQueryFactory = () => {
return {
query: gql`
query CurrentUserSessions {
currentUserSessions {
id
workspaceId
authProvider
isImpersonating
isCurrent
lastActiveAt
expiresAt
}
}
`,
variables: {},
};
};
export const revokeUserSessionQueryFactory = ({
userSessionId,
}: {
userSessionId: string;
}) => {
return {
query: gql`
mutation RevokeUserSession($userSessionId: UUID!) {
revokeUserSession(userSessionId: $userSessionId)
}
`,
variables: { userSessionId },
};
};
export const revokeAllOtherUserSessionsQueryFactory = () => {
return {
query: gql`
mutation RevokeAllOtherUserSessions {
revokeAllOtherUserSessions
}
`,
variables: {},
};
};
export const signOutQueryFactory = ({
refreshToken,
}: {
refreshToken?: string;
} = {}) => {
return {
query: gql`
mutation SignOut($refreshToken: String) {
signOut(refreshToken: $refreshToken)
}
`,
variables: { refreshToken },
};
};

View File

@@ -0,0 +1,112 @@
import {
extractSessionCookie,
postMetadataOperationWithHeaders,
signInWithCookieCapture,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/sign-in-with-cookie-capture.util';
import {
currentUserIdentityQueryFactory,
currentUserSessionsQueryFactory,
revokeAllOtherUserSessionsQueryFactory,
revokeUserSessionQueryFactory,
} from 'test/integration/graphql/suites/auth/user-sessions/utils/user-session-operations.util';
import { ALLOWED_ORIGIN } from 'test/integration/graphql/suites/auth/user-sessions/constants/session-origins.constants';
type UserSessionApiEntry = { id: string; isCurrent: boolean };
// Tim is seeded in both apple and yc, which is what makes this provable: one
// person, two workspaces. Almost nothing in Twenty is account-wide, so a
// workspace must not surface, or be able to revoke, the sessions this person
// holds in another workspace.
describe('workspace-scoped user sessions API (integration)', () => {
let appleCookieHeader: string;
let ycCookieHeader: string;
const signInTo = async (workspaceSubdomain: string): Promise<string> => {
const response = await signInWithCookieCapture({
workspaceSubdomain,
originHeader: ALLOWED_ORIGIN,
});
const sessionCookie = extractSessionCookie(response);
if (!sessionCookie) {
throw new Error(`Expected a session cookie from ${workspaceSubdomain}`);
}
return sessionCookie.cookieHeader;
};
const fetchSessions = async (
cookieHeader: string,
): Promise<UserSessionApiEntry[]> => {
const response = await postMetadataOperationWithHeaders(
currentUserSessionsQueryFactory(),
{ originHeader: ALLOWED_ORIGIN, cookieHeader },
);
expect(response.body.errors).toBeUndefined();
return response.body.data.currentUserSessions;
};
beforeAll(async () => {
appleCookieHeader = await signInTo('apple');
ycCookieHeader = await signInTo('yc');
});
it('should list only the sessions bound to the workspace the cookie belongs to', async () => {
const appleSessionIds = (await fetchSessions(appleCookieHeader)).map(
(session) => session.id,
);
const ycSessionIds = (await fetchSessions(ycCookieHeader)).map(
(session) => session.id,
);
expect(appleSessionIds.length).toBeGreaterThan(0);
expect(ycSessionIds.length).toBeGreaterThan(0);
expect(
appleSessionIds.filter((sessionId) => ycSessionIds.includes(sessionId)),
).toHaveLength(0);
});
it('should refuse to revoke a session belonging to another workspace', async () => {
const ycSessions = await fetchSessions(ycCookieHeader);
const ycSessionId = ycSessions[0].id;
const response = await postMetadataOperationWithHeaders(
revokeUserSessionQueryFactory({ userSessionId: ycSessionId }),
{ originHeader: ALLOWED_ORIGIN, cookieHeader: appleCookieHeader },
);
expect(response.body.errors).toBeDefined();
// Still usable: the refusal has to be a no-op, not a silent revocation.
const ycSessionsAfter = await fetchSessions(ycCookieHeader);
expect(ycSessionsAfter.map((session) => session.id)).toContain(ycSessionId);
});
it('should leave other workspaces signed in when revoking all other devices', async () => {
// A second apple sign-in guarantees there is something to revoke.
await signInTo('apple');
const response = await postMetadataOperationWithHeaders(
revokeAllOtherUserSessionsQueryFactory(),
{ originHeader: ALLOWED_ORIGIN, cookieHeader: appleCookieHeader },
);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.revokeAllOtherUserSessions).toBeGreaterThanOrEqual(
1,
);
const ycResponse = await postMetadataOperationWithHeaders(
currentUserIdentityQueryFactory(),
{ originHeader: ALLOWED_ORIGIN, cookieHeader: ycCookieHeader },
);
expect(ycResponse.body.errors).toBeUndefined();
expect(ycResponse.body.data.currentUser.email).toBe('tim@apple.dev');
});
});