Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/api/src/email/templates/access-reclaim.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ export const AccessReclaimEmail = ({
<br />
<Section>
<Text className="text-[12px] leading-[24px] text-[#666666]">
This link will expire in 24 hours. Your grant expires on:{' '}
This link will remain valid until your access expires on:{' '}
<strong>
{expiresAt.toLocaleDateString('en-US', {
year: 'numeric',
Expand Down
66 changes: 66 additions & 0 deletions apps/api/src/trust-portal/trust-access.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,23 @@ describe('TrustAccessService resendAccessGrantEmail NDA copy', () => {
expect.objectContaining({ ndaBypassed: false }),
);
});

it('rotates an expired token to expire with the grant, not a fixed 24h window', async () => {
const grantExpiresAt = new Date(Date.now() + 10 * 24 * 60 * 60 * 1000);
mockDb.trustAccessGrant.findFirst.mockResolvedValue({
...baseGrant,
expiresAt: grantExpiresAt,
accessTokenExpiresAt: new Date(Date.now() - 1000),
ndaAgreement: null,
});

await service.resendAccessGrantEmail('org_1', 'tag_1');

expect(mockDb.trustAccessGrant.update).toHaveBeenCalledWith({
where: { id: 'tag_1' },
data: expect.objectContaining({ accessTokenExpiresAt: grantExpiresAt }),
});
});
});

describe('TrustAccessService signNda NDA copy', () => {
Expand Down Expand Up @@ -447,6 +464,55 @@ describe('TrustAccessService signNda NDA copy', () => {
});
});

describe('TrustAccessService reclaimAccess token rotation', () => {
const emailService = {
sendAccessReclaimEmail: jest.fn(),
};
const service = new TrustAccessService(
{} as any,
emailService as any,
{} as any,
{} as any,
{} as any,
);
jest
.spyOn(service as any, 'buildPortalAccessUrl')
.mockResolvedValue('https://portal.example.com/access/token');

beforeEach(() => {
jest.clearAllMocks();
mockDb.trust.findUnique.mockResolvedValue({
organizationId: 'org_1',
friendlyUrl: 'acme-security',
status: 'published',
});
});

it('rotates an expired access token to expire with the grant, not a fixed 24h window', async () => {
const grantExpiresAt = new Date(Date.now() + 20 * 24 * 60 * 60 * 1000);
mockDb.trustAccessGrant.findFirst.mockResolvedValue({
id: 'tag_1',
subjectEmail: 'chang.liu@client.com',
status: 'active',
expiresAt: grantExpiresAt,
accessToken: 'stale-token',
accessTokenExpiresAt: new Date(Date.now() - 1000),
accessRequest: {
name: 'Chang Liu',
organization: { name: 'Acme Security' },
},
ndaAgreement: null,
});

await service.reclaimAccess('acme-security', 'chang.liu@client.com');

expect(mockDb.trustAccessGrant.update).toHaveBeenCalledWith({
where: { id: 'tag_1' },
data: expect.objectContaining({ accessTokenExpiresAt: grantExpiresAt }),
});
});
});

describe('TrustAccessService access request notification', () => {
const emailService = {
sendAccessRequestNotification: jest.fn(),
Expand Down
31 changes: 18 additions & 13 deletions apps/api/src/trust-portal/trust-access.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,9 @@ export class TrustAccessService {
accessTokenExpiresAt < new Date()
) {
accessToken = this.generateToken(32);
accessTokenExpiresAt = new Date();
accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 24);
// Mirror the grant's own expiry rather than a fixed window, so the
// emailed link stays valid for the whole approved duration.
accessTokenExpiresAt = existingGrant.expiresAt;

await db.trustAccessGrant.update({
where: { id: existingGrant.id },
Expand Down Expand Up @@ -706,8 +707,9 @@ export class TrustAccessService {
expiresAt.setDate(expiresAt.getDate() + durationDays);

const accessToken = this.generateToken(32);
const accessTokenExpiresAt = new Date();
accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 24);
// Mirror the grant's own expiry rather than a fixed window, so the
// emailed link stays valid for the whole approved duration.
const accessTokenExpiresAt = expiresAt;

const result = await db.$transaction(async (tx) => {
const updatedRequest = await tx.trustAccessRequest.update({
Expand Down Expand Up @@ -1011,9 +1013,9 @@ export class TrustAccessService {
(grant.accessTokenExpiresAt && grant.accessTokenExpiresAt < now)
) {
accessToken = this.generateToken(32);
const accessTokenExpiresAt = new Date(
now.getTime() + 24 * 60 * 60 * 1000,
);
// Mirror the grant's own expiry rather than a fixed window, so the
// emailed link stays valid for the whole approved duration.
const accessTokenExpiresAt = grant.expiresAt;

await db.trustAccessGrant.update({
where: { id: grantId },
Expand Down Expand Up @@ -1156,9 +1158,10 @@ export class TrustAccessService {
: null;

const accessToken = nda.grant.accessToken || this.generateToken(32);
// Mirror the grant's own expiry rather than a fixed window, so the
// emailed link stays valid for the whole approved duration.
const accessTokenExpiresAt =
nda.grant.accessTokenExpiresAt ||
new Date(Date.now() + 24 * 60 * 60 * 1000);
nda.grant.accessTokenExpiresAt || nda.grant.expiresAt;

if (!nda.grant.accessToken) {
await db.trustAccessGrant.update({
Expand Down Expand Up @@ -1204,8 +1207,9 @@ export class TrustAccessService {
expiresAt.setDate(expiresAt.getDate() + durationDays);

const accessToken = this.generateToken(32);
const accessTokenExpiresAt = new Date();
accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 24);
// Mirror the grant's own expiry rather than a fixed window, so the
// emailed link stays valid for the whole approved duration.
const accessTokenExpiresAt = expiresAt;

const result = await db.$transaction(async (tx) => {
const grant = await tx.trustAccessGrant.create({
Expand Down Expand Up @@ -1442,8 +1446,9 @@ export class TrustAccessService {
accessTokenExpiresAt < new Date()
) {
accessToken = this.generateToken(32);
accessTokenExpiresAt = new Date();
accessTokenExpiresAt.setHours(accessTokenExpiresAt.getHours() + 24);
// Mirror the grant's own expiry rather than a fixed window, so the
// emailed link stays valid for the whole approved duration.
accessTokenExpiresAt = grant.expiresAt;

await db.trustAccessGrant.update({
where: { id: grant.id },
Expand Down
Loading