Skip to content

feat: email team admins when SAML certificates are nearing expiration - #1895

Open
paustint wants to merge 1 commit into
mainfrom
feat/sso-expiration-notification
Open

feat: email team admins when SAML certificates are nearing expiration#1895
paustint wants to merge 1 commit into
mainfrom
feat/sso-expiration-notification

Conversation

@paustint

Copy link
Copy Markdown
Contributor

Notify customers of upcoming SAML certification expiration

In-app notification of upcoming expiration

Cron job for automated email reminders

Copilot AI review requested due to automatic review settings July 31, 2026 00:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds end-to-end SAML IdP certificate expiration notifications: it stores a per-team reminder schedule in the DB, surfaces an in-app admin banner via /heartbeat announcements, and runs a cron task to email team admins as thresholds are reached.

Changes:

  • Add nextCertNotificationDate / lastCertNotificationAt to SamlConfiguration, including migrations + a backfill to seed schedules for existing configs.
  • Introduce shared scheduling utilities + unit tests, and wire them into the SAML config save path (to restart the schedule when the expiration changes).
  • Add a new cron entry + integration tests, plus a new email template/sender and new audit log actions.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
prisma/schema.prisma Adds schedule tracking fields to SAML configuration model.
prisma/migrations/20260730153103_add_sso_cert_expiration_notifications/migration.sql Adds DB columns for SSO cert notification scheduling.
prisma/migrations/20260730154407_backfill_sso_cert_notification_schedule/migration.sql Seeds nextCertNotificationDate for pre-existing SAML configs.
package.json Adds start scripts for the new SSO cert expiration cron.
libs/shared/utils/src/lib/sso-certificate-expiration.ts Shared schedule + day-count utilities for API + cron.
libs/shared/utils/src/lib/tests/sso-certificate-expiration.spec.ts Unit tests for shared schedule/day-count logic.
libs/shared/utils/src/index.ts Re-exports the new shared SSO cert expiration utilities.
libs/email/src/lib/email.tsx Adds an email sender for SSO cert expiration reminders.
libs/email/src/lib/email-templates/auth/SsoCertificateExpirationEmail.tsx New react-email template for SSO cert expiration messaging.
libs/audit-logs/src/lib/audit-logs.ts Adds audit log actions for warning/expired events.
apps/cron-tasks/src/utils/sso-certificate-expiration.utils.ts Core cron logic: select due configs, email admins, advance schedule, audit log.
apps/cron-tasks/src/sso-certificate-expiration.ts Cron entrypoint wiring for the new task.
apps/cron-tasks/src/tests/sso-certificate-expiration.spec.ts Integration tests for cron behavior against a test DB.
apps/cron-tasks/project.json Includes the new cron entrypoint in the build inputs.
apps/api/src/app/routes/api.routes.ts Makes /heartbeat async to include dynamic announcements.
apps/api/src/app/db/team.db.ts Seeds/resets notification schedule on SAML config save; adds minimal expiration lookup.
apps/api/src/app/controllers/team.controller.ts Clears announcement cache when SSO settings/cert change.
apps/api/src/app/announcements.ts Adds cached per-team “SSO cert expiring/expired” admin announcement.

Comment thread libs/shared/utils/src/lib/sso-certificate-expiration.ts
Copilot AI review requested due to automatic review settings July 31, 2026 02:21
@paustint
paustint force-pushed the feat/sso-expiration-notification branch from 87fcdc4 to 118289f Compare July 31, 2026 02:21

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.

Suppressed comments (5)

prisma/schema.prisma:745

  • The cron queries sso_saml_configurations by nextCertNotificationDate <= now and idpCertificateExpiresAt IS NOT NULL, but SamlConfiguration has no index on nextCertNotificationDate. This can become a full-table scan as teams grow; there’s precedent for indexing similar notification schedule fields (e.g. SalesforceOrg.nextExpirationNotificationDate is indexed).
  // Drives the certificate expiration notification cron. nextCertNotificationDate is the date the next
  // reminder is due (null = nothing further to send) and is reset whenever the certificate changes so a
  // renewed certificate restarts the reminder schedule.
  nextCertNotificationDate DateTime?
  lastCertNotificationAt   DateTime?

libs/email/src/lib/email.tsx:327

  • This treats daysUntilExpiration === 0 as "expired". Elsewhere (e.g. SsoCertificateExpirationEmailProps and getDaysUntilCertExpiration docs) the negative range represents "already expired" and 0 is the expiration date itself, so the subject line is incorrect on the expiration day.
  const { daysUntilExpiration } = props;
  const subject =
    daysUntilExpiration <= 0
      ? 'Action Required: Your SSO Certificate Has Expired'
      : `Action Required: Your SSO Certificate Expires in ${daysUntilExpiration} ${pluralizeFromNumber('Day', daysUntilExpiration)}`;

libs/email/src/lib/email-templates/auth/SsoCertificateExpirationEmail.tsx:30

  • daysUntilExpiration is documented as negative only once the certificate has already expired; 0 represents the expiration date itself. Using <= 0 marks certificates expiring today as already expired, which makes the heading/preview incorrect on the expiration date.
  const hasExpired = daysUntilExpiration <= 0;
  const heading = hasExpired ? 'SSO Certificate Expired' : 'SSO Certificate Expiring Soon';
  const preview = hasExpired
    ? `The SAML signing certificate for ${teamName} has expired`
    : `The SAML signing certificate for ${teamName} expires in ${daysUntilExpiration} ${pluralizeFromNumber('day', daysUntilExpiration)}`;

apps/api/src/app/announcements.ts:70

  • Same off-by-one semantics as the email: daysUntilExpiration is negative only once the cert is already expired, but <= 0 marks "expires today" as expired. This will show the "Expired" banner on the expiration date even if the cert hasn’t actually expired yet.
  const expirationDate = certificate.expiresAt.toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    timeZone: 'UTC',
  });
  const hasExpired = daysUntilExpiration <= 0;

apps/cron-tasks/src/utils/sso-certificate-expiration.utils.ts:167

  • daysUntilExpiration === 0 is the expiration date itself (negative is "already expired"), so using <= 0 will log the "expired" audit action on the expiration date rather than only after it has passed.
    await createAuditLog({
      teamId: team.id,
      action: daysUntilExpiration <= 0 ? AuditLogAction.SSO_CERT_EXPIRED : AuditLogAction.SSO_CERT_EXPIRATION_WARNING,
      resource: AuditLogResource.TEAM_SSO_CONFIG,

Comment thread apps/api/src/app/db/team.db.ts
Copilot AI review requested due to automatic review settings July 31, 2026 14:42
@paustint
paustint force-pushed the feat/sso-expiration-notification branch from 118289f to a03ac7e Compare July 31, 2026 14:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

Suppressed comments (4)

apps/cron-tasks/src/utils/sso-certificate-expiration.utils.ts:97

  • Team status is compared against the hard-coded string 'ACTIVE'. Prefer the shared TEAM_STATUS_ACTIVE constant (from @jetstream/types) so this stays consistent with the TeamStatus schema and avoids drift if the enum values ever change.
    if (team.status !== 'ACTIVE' || !config.loginConfiguration.ssoEnabled) {

prisma/schema.prisma:746

  • SamlConfiguration is queried by the new cron on nextCertNotificationDate (lte: now), but the model doesn’t define an index for this column. Similar cron-driven fields (e.g. SalesforceOrg.nextExpirationNotificationDate) are indexed in the schema, and without an index this cron will require a full table scan as the table grows.

Consider adding @@index([nextCertNotificationDate]) (and running a Prisma migration) to keep the cron query efficient.

  // Drives the certificate expiration notification cron. nextCertNotificationDate is the date the next
  // reminder is due (null = nothing further to send) and is reset whenever the certificate changes so a
  // renewed certificate restarts the reminder schedule.
  nextCertNotificationDate DateTime?
  lastCertNotificationAt   DateTime?

apps/api/src/app/db/team.db.ts:1309

  • The reminder schedule reset is currently driven only by idpCertificateExpiresAt changing. If the IdP signing certificate rotates but happens to parse to the same expiration timestamp, the notification schedule won’t restart (and lastCertNotificationAt won’t clear), which contradicts the intent described in the comment (“rotating the certificate restarts the … schedule”). Since previousSamlConfig includes the stored idpCertificate, you can also key off the certificate value changing so the schedule reliably resets on actual certificate rotation.
  // Rotating the certificate restarts the expiration reminder schedule, so an admin who renews after
  // the 7-day warning still gets the full 30/14/7/3 sequence against the new expiration date. An
  // unchanged expiration leaves the existing schedule alone — otherwise any unrelated edit to the
  // config (renaming it, tweaking attribute mapping) would replay reminders the team already got.
  const certificateExpirationChanged = previousSamlConfig?.idpCertificateExpiresAt?.getTime() !== idpCertificateExpiresAt?.getTime();

apps/cron-tasks/src/utils/sso-certificate-expiration.utils.ts:5

  • This file already uses @jetstream/types role/status constants; importing TEAM_STATUS_ACTIVE as well will let the team-status check avoid hard-coded strings and match the shared TeamStatus schema constants.

This issue also appears on line 97 of the same file.

import { TEAM_MEMBER_ROLE_ADMIN, TEAM_MEMBER_STATUS_ACTIVE } from '@jetstream/types';

Copilot AI review requested due to automatic review settings July 31, 2026 15:14
@paustint
paustint force-pushed the feat/sso-expiration-notification branch from a03ac7e to ae2b5c7 Compare July 31, 2026 15:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

Suppressed comments (1)

apps/api/src/app/announcements.ts:100

  • If getSsoCertificateAnnouncement() (DB/cache) throws, the catch currently returns [], which drops all announcements (including the static ones). Since the SSO announcement is best-effort, it’s safer to fall back to getStaticAnnouncements() so the heartbeat still returns the baseline announcements even during transient DB/cache failures.
export async function getAnnouncements(user?: Maybe<AuthenticatedUser>): Promise<Announcement[]> {
  try {
    const announcements = getStaticAnnouncements();

    if (user) {
      const ssoCertAnnouncement = await getSsoCertificateAnnouncement(user);
      if (ssoCertAnnouncement) {
        announcements.push(ssoCertAnnouncement);
      }
    }

    return announcements;
  } catch (ex) {
    logger.error({ ...getErrorMessageAndStackObj(ex) }, 'Failed to get announcements');
    return [];
  }

@paustint
paustint force-pushed the feat/sso-expiration-notification branch from ae2b5c7 to d0042b8 Compare August 1, 2026 18:46
Copilot AI review requested due to automatic review settings August 1, 2026 18:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.

Suppressed comments (6)

libs/email/src/lib/email-templates/auth/SsoCertificateExpirationEmail.tsx:56

  • When daysUntilExpiration === 0 (expiration date), this renders “Expires in 0 days”. Special-case 0 to display “Expires today” and keep “Expired” only for negative values.
              <span style={hasExpired ? expiredText : expirationText}>
                {hasExpired ? '⚠️ Expired' : `Expires in ${daysUntilExpiration} ${pluralizeFromNumber('day', daysUntilExpiration)}`}
              </span>

apps/jetstream/src/app/components/core/AnnouncementAlerts.tsx:64

  • link.to comes from the API and is currently /teams, but in the browser extension the app’s routes are *.html (via APP_ROUTES/__IS_BROWSER_EXTENSION__). This makes the announcement link navigate to a non-existent route in the extension. Resolve the route client-side when running in the extension so /teams becomes /teams.html.
      {content.slice(0, placeholderIndex)}
      <Link to={link.to}>{link.label}</Link>
      {content.slice(placeholderIndex + link.key.length)}

apps/api/src/app/announcements.ts:73

  • daysUntilExpiration is documented as “negative once expired”, so 0 means “expires today”, not “already expired”. Using <= 0 here will show an “expired” banner for the entire expiration date even if the cert expires later that day.
  const hasExpired = daysUntilExpiration <= 0;

libs/email/src/lib/email.tsx:327

  • daysUntilExpiration is negative once expired, so 0 means “expires today”. The current subject treats 0 as expired, which is inconsistent with that meaning and also avoids a clearer “expires today” subject.
  const subject =
    daysUntilExpiration <= 0
      ? 'Action Required: Your SSO Certificate Has Expired'
      : `Action Required: Your SSO Certificate Expires in ${daysUntilExpiration} ${pluralizeFromNumber('Day', daysUntilExpiration)}`;

apps/cron-tasks/src/utils/sso-certificate-expiration.utils.ts:188

  • daysUntilExpiration is negative once expired; on the expiration date itself (0) this should still be a warning, not an “expired” audit action.
    await createAuditLog({
      teamId: team.id,
      action: daysUntilExpiration <= 0 ? AuditLogAction.SSO_CERT_EXPIRED : AuditLogAction.SSO_CERT_EXPIRATION_WARNING,
      resource: AuditLogResource.TEAM_SSO_CONFIG,

libs/email/src/lib/email-templates/auth/SsoCertificateExpirationEmail.tsx:34

  • daysUntilExpiration is documented as negative once expired; treating 0 as expired makes the email read incorrectly on the expiration date. Handle the 0 case explicitly (“expires today”) and reserve “expired” for negative values.

This issue also appears on line 54 of the same file.

  const hasExpired = daysUntilExpiration <= 0;
  const heading = hasExpired ? 'SSO Certificate Expired' : 'SSO Certificate Expiring Soon';
  const preview = hasExpired
    ? `The SAML signing certificate for ${teamName} has expired`
    : `The SAML signing certificate for ${teamName} expires in ${daysUntilExpiration} ${pluralizeFromNumber('day', daysUntilExpiration)}`;

Copilot AI review requested due to automatic review settings August 1, 2026 22:15
@paustint
paustint force-pushed the feat/sso-expiration-notification branch from d0042b8 to 3bb7406 Compare August 1, 2026 22:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.

Suppressed comments (1)

prisma/schema.prisma:745

  • nextCertNotificationDate will be used as the primary filter in the cron query (WHERE nextCertNotificationDate <= now AND nextCertNotificationDate IS NOT NULL). Unlike the analogous salesforce_org.nextExpirationNotificationDate, this field is not indexed, which can force a full scan of sso_saml_configurations as the number of teams grows. Add an index on nextCertNotificationDate (and include the corresponding migration) to keep the cron query efficient.
  // Drives the certificate expiration notification cron. nextCertNotificationDate is the date the next
  // reminder is due (null = nothing further to send) and is reset whenever the certificate changes so a
  // renewed certificate restarts the reminder schedule.
  nextCertNotificationDate DateTime?
  lastCertNotificationAt   DateTime?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants