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 },