Ignore expired invitations in invitation lookups (#23749)

## What

Invitation lookups did not filter on `expiresAt`, so expired invitations
were still treated as active. This aligns them with the sibling
`findInvitationsByEmail`, which already applied that filter.

- `WorkspaceInvitationService.getOneWorkspaceInvitation` - added
`deletedAt IS NULL` and `expiresAt > now` (also converted to a typed
`findOne` so the column references are checked).
- `AuthService.findInvitationForSignInUp` - added `expiresAt > now` (it
already filtered `deletedAt`).
- `throwIfOnboardingInvitationLimitReached` - expired tokens no longer
count toward the onboarding invitation limit.
- `createWorkspaceInvitation` - deletes the expired token for that email
before issuing a replacement, so re-invites don't accumulate stale rows.

## Why

Without the filter, an expired pending invitation behaved as if it were
still active:

- On sign-up with a personal invite token, an expired invitation still
granted access to the workspace.
- Re-inviting an email whose invitation had lapsed reported
`INVITATION_ALREADY_EXIST` instead of sending a fresh invite.
- Expired onboarding invitations still consumed quota, so the limit
could be hit by invitations nobody could use.

Once expired tokens are ignored on read, a re-invite would leave the old
row behind, so `createWorkspaceInvitation` now removes it. The delete is
scoped to the same workspace, invitation token types, that exact email,
and `expiresAt <= now`, so it can only remove tokens that are already
unusable.

Closes twentyhq/private-issues#503

## Tests

Integration suites added, run against a real database:

- `auth/sign-up/failing-sign-up-with-expired-invitation` - expired
personal invitation is rejected (snapshot asserts the specific
`FORBIDDEN` error).
- `auth/sign-up/successful-sign-up-with-valid-invitation` - positive
control: a valid invitation still grants access, so the rejection above
cannot pass for an unrelated reason.
- `expired-workspace-invitation` - re-invite over an expired invitation
succeeds and leaves exactly one (fresh) token; a valid invitation is
still reported as already existing.

Each assertion was verified to fail when its corresponding filter is
removed. Unit tests (`workspace-invitation.service.spec.ts`,
`auth.service.spec.ts`), typecheck, and lint all pass.

Not included: invitations that expire and are never re-invited still
linger, since no cron reaps invitation tokens today.
This commit is contained in:
Paul Rastoin
2026-08-04 17:35:41 +02:00
committed by GitHub
parent be051c8724
commit 3833015626
10 changed files with 342 additions and 22 deletions
@@ -807,7 +807,10 @@ export class AuthService {
.andWhere('"appToken".type IN (:...types)', {
types: INVITATION_APP_TOKEN_TYPES,
})
.andWhere('"appToken"."deletedAt" IS NULL');
.andWhere('"appToken"."deletedAt" IS NULL')
.andWhere('"appToken"."expiresAt" > :now', {
now: new Date(),
});
if ('workspacePersonalInviteToken' in params) {
qr.andWhere('"appToken".value = :personalInviteToken', {
@@ -1,7 +1,7 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { type DeleteResult, Repository } from 'typeorm';
import {
AppTokenEntity,
@@ -155,11 +155,10 @@ describe('WorkspaceInvitationService', () => {
const email = 'test@example.com';
const workspace = { id: 'workspace-id' } as WorkspaceEntity;
jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
} as any);
jest.spyOn(appTokenRepository, 'findOne').mockResolvedValue(null);
jest
.spyOn(appTokenRepository, 'delete')
.mockResolvedValue({} as DeleteResult);
jest.spyOn(userWorkspaceRepository, 'exists').mockResolvedValue(false);
jest
@@ -175,11 +174,9 @@ describe('WorkspaceInvitationService', () => {
const email = 'test@example.com';
const workspace = { id: 'workspace-id' } as WorkspaceEntity;
jest.spyOn(appTokenRepository, 'createQueryBuilder').mockReturnValue({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({}),
} as any);
jest
.spyOn(appTokenRepository, 'findOne')
.mockResolvedValue({} as AppTokenEntity);
await expect(
service.createWorkspaceInvitation(email, workspace),
@@ -9,7 +9,14 @@ import ms from 'ms';
import { SendInviteLinkEmail, renderEmail } from 'twenty-emails';
import { AppPath, FileFolder } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { In, IsNull, Repository } from 'typeorm';
import {
In,
IsNull,
LessThanOrEqual,
MoreThan,
Raw,
Repository,
} from 'typeorm';
import {
AppTokenEntity,
@@ -109,16 +116,15 @@ export class WorkspaceInvitationService {
}
async getOneWorkspaceInvitation(workspaceId: string, email: string) {
return await this.appTokenRepository
.createQueryBuilder('appToken')
.where('"appToken"."workspaceId" = :workspaceId', {
return await this.appTokenRepository.findOne({
where: {
workspaceId,
})
.andWhere('"appToken".type IN (:...types)', {
types: INVITATION_APP_TOKEN_TYPES,
})
.andWhere('"appToken".context->>\'email\' = :email', { email })
.getOne();
type: In(INVITATION_APP_TOKEN_TYPES),
deletedAt: IsNull(),
expiresAt: MoreThan(new Date()),
context: Raw((alias) => `${alias} ->> 'email' = :email`, { email }),
},
});
}
async getAppTokenByInvitationToken(invitationToken: string) {
@@ -192,6 +198,15 @@ export class WorkspaceInvitationService {
);
}
await this.appTokenRepository.delete({
workspaceId: workspace.id,
type: In(INVITATION_APP_TOKEN_TYPES),
expiresAt: LessThanOrEqual(new Date()),
context: Raw((alias) => `${alias} ->> 'email' = :email`, {
email: email.toLowerCase(),
}),
});
return this.generateInvitationToken(
workspace.id,
email,
@@ -465,6 +480,7 @@ export class WorkspaceInvitationService {
workspaceId,
type: AppTokenType.OnboardingInvitationToken,
deletedAt: IsNull(),
expiresAt: MoreThan(new Date()),
},
});
@@ -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",
}
`;
@@ -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 });
});
});
@@ -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();
});
});
@@ -0,0 +1,62 @@
import {
deleteWorkspaceInvitationsByEmail,
findWorkspaceInvitationsByEmail,
seedWorkspaceInvitation,
} from 'test/integration/graphql/utils/seed-workspace-invitation.util';
import { sendInvitationsOperationFactory } from 'test/integration/graphql/utils/send-invitations-operation-factory.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
const ONE_HOUR_IN_MS = 60 * 60 * 1000;
describe('sendInvitations expired invitation handling (integration)', () => {
const sendInvitations = (email: string) =>
makeMetadataAPIRequest(
sendInvitationsOperationFactory({ emails: [email] }),
);
it('re-invites an email whose only existing invitation is expired', async () => {
const email = `expired-invite-resend-${Date.now()}@example.com`;
const staleToken = `expired-invite-resend-token-${Date.now()}`;
await seedWorkspaceInvitation({
email,
value: staleToken,
expiresAt: new Date(Date.now() - ONE_HOUR_IN_MS),
});
try {
const response = await sendInvitations(email);
expect(response.body.errors).toBeUndefined();
expect(response.body.data.sendInvitations.success).toBe(true);
const remainingTokens = await findWorkspaceInvitationsByEmail({ email });
expect(remainingTokens).toHaveLength(1);
expect(remainingTokens[0].value).not.toBe(staleToken);
expect(new Date(remainingTokens[0].expiresAt).getTime()).toBeGreaterThan(
Date.now(),
);
} finally {
await deleteWorkspaceInvitationsByEmail({ email });
}
});
it('still reports a valid invitation as already existing', async () => {
const email = `valid-invite-resend-${Date.now()}@example.com`;
await seedWorkspaceInvitation({
email,
value: `valid-invite-resend-token-${Date.now()}`,
expiresAt: new Date(Date.now() + ONE_HOUR_IN_MS),
});
try {
const response = await sendInvitations(email);
expect(response.body.data.sendInvitations.success).toBe(false);
} finally {
await deleteWorkspaceInvitationsByEmail({ email });
}
});
});
@@ -0,0 +1,49 @@
import { AppTokenType } from 'src/engine/core-modules/app-token/app-token.entity';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
export const seedWorkspaceInvitation = ({
email,
value,
expiresAt,
workspaceId = SEED_APPLE_WORKSPACE_ID,
}: {
email: string;
value: string;
expiresAt: Date;
workspaceId?: string;
}) =>
testDataSource.query(
`INSERT INTO core."appToken" ("workspaceId", "type", "value", "expiresAt", "context")
VALUES ($1, $2, $3, $4, $5::jsonb)`,
[
workspaceId,
AppTokenType.InvitationToken,
value,
expiresAt.toISOString(),
JSON.stringify({ email }),
],
);
export const findWorkspaceInvitationsByEmail = ({
email,
workspaceId = SEED_APPLE_WORKSPACE_ID,
}: {
email: string;
workspaceId?: string;
}): Promise<{ value: string; expiresAt: string }[]> =>
testDataSource.query(
`SELECT "value", "expiresAt" FROM core."appToken" WHERE "workspaceId" = $1 AND context->>'email' = $2`,
[workspaceId, email],
);
export const deleteWorkspaceInvitationsByEmail = ({
email,
workspaceId = SEED_APPLE_WORKSPACE_ID,
}: {
email: string;
workspaceId?: string;
}) =>
testDataSource.query(
`DELETE FROM core."appToken" WHERE "workspaceId" = $1 AND context->>'email' = $2`,
[workspaceId, email],
);
@@ -0,0 +1,18 @@
import gql from 'graphql-tag';
export const sendInvitationsOperationFactory = ({
emails,
roleId,
}: {
emails: string[];
roleId?: string;
}) => ({
query: gql`
mutation SendInvitations($emails: [String!]!, $roleId: UUID) {
sendInvitations(emails: $emails, roleId: $roleId) {
success
}
}
`,
variables: { emails, roleId },
});
@@ -0,0 +1,50 @@
import gql from 'graphql-tag';
export const signUpInWorkspaceOperationFactory = ({
email,
password = 'Test123!@#',
workspaceId,
workspaceInviteHash,
workspacePersonalInviteToken,
}: {
email: string;
password?: string;
workspaceId?: string;
workspaceInviteHash?: string;
workspacePersonalInviteToken?: string;
}) => ({
query: gql`
mutation SignUpInWorkspace(
$email: String!
$password: String!
$workspaceId: UUID
$workspaceInviteHash: String
$workspacePersonalInviteToken: String
) {
signUpInWorkspace(
email: $email
password: $password
workspaceId: $workspaceId
workspaceInviteHash: $workspaceInviteHash
workspacePersonalInviteToken: $workspacePersonalInviteToken
) {
loginToken {
token
}
workspace {
id
workspaceUrls {
subdomainUrl
}
}
}
}
`,
variables: {
email,
password,
workspaceId,
workspaceInviteHash,
workspacePersonalInviteToken,
},
});