Files
Verso/services/web/scripts/oauth/notify_expiring_tokens.mjs
T
e53c6f2aea Notify users about expiring git PATs and expose PATs in admin panel (#33802)
* Allow admin access to user PATs

* Tests for new screen in admin panel

* Adding error for invalid token and way to parse error for OAuth 2

* Git bridge handles expired PAT

* Script for alerting on close to expiry and expired git tokens

* Refactoring and simplifying

* Updating email templates to match agreed docs

* tweak to email subject to include Overleaf

* Allowing dry run in scripts and general tidy up

* removing redundant tests and dry running script

* Fixing CI errors

* Adding new tab to admin test expectation

* Address PR feedback on oauth2-server changes

- Replace ad-hoc overleafErrorCode prop with a TokenExpiredError subclass
- Collapse listTokens/listTokensForAdmin into a single hook

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Adding cron definitions for alerting on expiring git pat

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
GitOrigin-RevId: 69b9fd901a201592a580c69abe7bd7d603e85d3a
2026-06-04 08:05:26 +00:00

139 lines
3.8 KiB
JavaScript

// Recurring job. Sends two kinds of email about personal access token expiry:
//
// - "expiring soon": token has not expired yet and falls within the
// configured warning window
// (Settings.personalAccessTokens.expiry.warningWindowDays, default 2 days).
// Sent at most once per token.
//
// - "expired": token has expired and we have not yet emailed the owner.
// Sent at most once per token. Tokens that were already expired before
// the feature shipped have `notificationsSuppressedAt` set by the
// backfill_suppress_expired_token_notifications.mjs script and are
// deliberately excluded.
//
// Pass --dry-run to log who would be emailed without sending or marking
// `lastNotifiedAt`.
import settings from '@overleaf/settings'
import logger from '@overleaf/logger'
import {
db,
READ_PREFERENCE_SECONDARY,
} from '../../app/src/infrastructure/mongodb.mjs'
import { User } from '../../app/src/models/User.mjs'
import EmailHandler from '../../app/src/Features/Email/EmailHandler.mjs'
import { scriptRunner } from '../lib/ScriptRunner.mjs'
const MS_PER_DAY = 24 * 60 * 60 * 1000
export async function main({ dryRun = false } = {}) {
const now = new Date()
const warningWindowDays =
settings.personalAccessTokens.expiry.warningWindowDays
const warningHorizon = new Date(
now.getTime() + warningWindowDays * MS_PER_DAY
)
logger.info(
{ warningWindowDays, warningHorizon, dryRun },
'starting notify_expiring_tokens'
)
const warningCount = await processBucket({
kind: 'warning',
template: 'gitTokenExpiringSoon',
dryRun,
query: {
type: 'pat',
accessTokenExpiresAt: { $gt: now, $lte: warningHorizon },
'lastNotifiedAt.warning': { $exists: false },
},
})
const expiredCount = await processBucket({
kind: 'expired',
template: 'gitTokenExpired',
dryRun,
query: {
type: 'pat',
accessTokenExpiresAt: { $lt: now },
'lastNotifiedAt.expired': { $exists: false },
notificationsSuppressedAt: { $exists: false },
},
})
logger.info(
{ warningCount, expiredCount, dryRun },
'finished notify_expiring_tokens'
)
}
export async function processBucket({ kind, template, query, dryRun = false }) {
const cursor = db.oauthAccessTokens.find(query, {
projection: {
_id: 1,
user_id: 1,
},
readPreference: READ_PREFERENCE_SECONDARY,
})
let sent = 0
for await (const token of cursor) {
const ok = await notifyOwner({ token, kind, template, dryRun })
if (ok) sent++
}
return sent
}
export async function notifyOwner({ token, kind, template, dryRun = false }) {
const user = await User.findOne(
{ _id: token.user_id },
{ email: 1, first_name: 1 }
).exec()
if (!user?.email) {
logger.warn(
{ tokenId: token._id, userId: token.user_id },
'skipping token notification: user not found or has no email'
)
return false
}
if (dryRun) {
logger.info(
{ tokenId: token._id, userId: token.user_id, kind, template },
'dry run: would send git token expiry notification'
)
return true
}
try {
await EmailHandler.promises.sendEmail(template, {
to: user.email,
firstName: user.first_name,
})
} catch (err) {
logger.error(
{ err, tokenId: token._id, userId: token.user_id, kind },
'failed to send git token expiry notification; will retry next run'
)
return false
}
await db.oauthAccessTokens.updateOne(
{ _id: token._id },
{ $set: { [`lastNotifiedAt.${kind}`]: new Date() } }
)
return true
}
if (import.meta.url === `file://${process.argv[1]}`) {
const dryRun = process.argv.includes('--dry-run')
try {
await scriptRunner(() => main({ dryRun }))
process.exit(0)
} catch (error) {
logger.error({ err: error }, 'notify_expiring_tokens failed')
process.exit(1)
}
}