diff --git a/apps/api/src/email/templates/access-reclaim.tsx b/apps/api/src/email/templates/access-reclaim.tsx index 4a42d7bda9..b614a5a060 100644 --- a/apps/api/src/email/templates/access-reclaim.tsx +++ b/apps/api/src/email/templates/access-reclaim.tsx @@ -88,7 +88,7 @@ export const AccessReclaimEmail = ({
- This link will expire in 24 hours. Your grant expires on:{' '} + This link will remain valid until your access expires on:{' '} {expiresAt.toLocaleDateString('en-US', { year: 'numeric', diff --git a/apps/api/src/trust-portal/trust-access.service.spec.ts b/apps/api/src/trust-portal/trust-access.service.spec.ts index 5443807487..e0bb9604ca 100644 --- a/apps/api/src/trust-portal/trust-access.service.spec.ts +++ b/apps/api/src/trust-portal/trust-access.service.spec.ts @@ -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', () => { @@ -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(), diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 899aad5c83..c57b1bbc39 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -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 }, @@ -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({ @@ -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 }, @@ -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({ @@ -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({ @@ -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 },