소스 검색

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
Liam O'Brien 2 달 전
부모
커밋
e53c6f2aea

+ 1 - 0
server-ce/test/admin.spec.ts

@@ -245,6 +245,7 @@ describe('admin panel', function () {
           'Deleted Projects',
           'Audit Log',
           'Sessions',
+          'Personal Access Tokens',
         ]
         cy.findAllByRole('tab').should('have.length', tabs.length)
         tabs.forEach(tabName => {

+ 62 - 7
services/git-bridge/src/main/java/uk/ac/ic/wlgitbridge/server/Oauth2Filter.java

@@ -5,6 +5,10 @@ import com.google.api.client.http.GenericUrl;
 import com.google.api.client.http.HttpHeaders;
 import com.google.api.client.http.HttpRequest;
 import com.google.api.client.http.HttpResponse;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonSyntaxException;
 import jakarta.servlet.*;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
@@ -107,15 +111,18 @@ public class Oauth2Filter implements Filter {
       // fail later (for example, in the unlikely event that the token
       // expired between the two requests). In that case, JGit will
       // return a 401 without a custom error message.
-      int statusCode = checkAccessToken(this.oauth2Server, password, getClientIp(request));
-      if (statusCode == 429) {
+      AccessTokenCheck check = checkAccessToken(this.oauth2Server, password, getClientIp(request));
+      if (check.statusCode == 429) {
         handleRateLimit(projectId, username, request, response);
         return;
-      } else if (statusCode == 401) {
+      } else if (check.statusCode == 401 && "token_expired".equals(check.errorCode)) {
+        handleExpiredAccessToken(projectId, request, response);
+        return;
+      } else if (check.statusCode == 401) {
         handleBadAccessToken(projectId, request, response);
         return;
-      } else if (statusCode >= 400) {
-        handleUnknownOauthServerError(projectId, statusCode, request, response);
+      } else if (check.statusCode >= 400) {
+        handleUnknownOauthServerError(projectId, check.statusCode, request, response);
         return;
       }
       cred.setAccessToken(password);
@@ -229,8 +236,52 @@ public class Oauth2Filter implements Filter {
             "https://www.overleaf.com/learn/how-to/Git_integration"));
   }
 
-  private int checkAccessToken(String oauth2Server, String accessToken, String clientIp)
+  private void handleExpiredAccessToken(
+      String projectId, HttpServletRequest request, HttpServletResponse response)
       throws IOException {
+    Log.debug("[{}] Expired access token, ip={}", projectId, getClientIp(request));
+    sendResponse(
+        response,
+        401,
+        Arrays.asList(
+            "Your Overleaf Git authentication token has expired.",
+            "",
+            "Generate a new authentication token in your Overleaf Account Settings,",
+            "then run the git command again."));
+  }
+
+  static class AccessTokenCheck {
+    final int statusCode;
+    final String errorCode;
+
+    AccessTokenCheck(int statusCode, String errorCode) {
+      this.statusCode = statusCode;
+      this.errorCode = errorCode;
+    }
+  }
+
+  static String parseErrorCode(String body) {
+    if (body == null || body.isEmpty()) {
+      return null;
+    }
+    try {
+      JsonElement element = new Gson().fromJson(body, JsonElement.class);
+      if (element == null || !element.isJsonObject()) {
+        return null;
+      }
+      JsonObject obj = element.getAsJsonObject();
+      JsonElement codeElement = obj.get("error_code");
+      if (codeElement == null || codeElement.isJsonNull()) {
+        return null;
+      }
+      return codeElement.getAsString();
+    } catch (JsonSyntaxException | UnsupportedOperationException e) {
+      return null;
+    }
+  }
+
+  private AccessTokenCheck checkAccessToken(
+      String oauth2Server, String accessToken, String clientIp) throws IOException {
     GenericUrl url = new GenericUrl(oauth2Server + "/oauth/token/info?client_ip=" + clientIp);
     HttpRequest request = Instance.httpRequestFactory.buildGetRequest(url);
     HttpHeaders headers = new HttpHeaders();
@@ -239,8 +290,12 @@ public class Oauth2Filter implements Filter {
     request.setThrowExceptionOnExecuteError(false);
     HttpResponse response = request.execute();
     int statusCode = response.getStatusCode();
+    String errorCode = null;
+    if (statusCode >= 400 && statusCode < 500) {
+      errorCode = parseErrorCode(response.parseAsString());
+    }
     response.disconnect();
-    return statusCode;
+    return new AccessTokenCheck(statusCode, errorCode);
   }
 
   private void handleUnknownOauthServerError(

+ 51 - 0
services/git-bridge/src/test/java/uk/ac/ic/wlgitbridge/server/Oauth2FilterTest.java

@@ -0,0 +1,51 @@
+package uk.ac.ic.wlgitbridge.server;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class Oauth2FilterTest {
+
+  @Test
+  public void parseErrorCode_returnsTokenExpired_whenBodyContainsIt() {
+    String body = "{\"error\":\"invalid_token\",\"error_code\":\"token_expired\"}";
+    Assert.assertEquals("token_expired", Oauth2Filter.parseErrorCode(body));
+  }
+
+  @Test
+  public void parseErrorCode_returnsTokenInvalid_whenBodyContainsIt() {
+    String body = "{\"error\":\"invalid_token\",\"error_code\":\"token_invalid\"}";
+    Assert.assertEquals("token_invalid", Oauth2Filter.parseErrorCode(body));
+  }
+
+  @Test
+  public void parseErrorCode_returnsNull_whenErrorCodeFieldIsMissing() {
+    String body = "{\"error\":\"invalid_token\"}";
+    Assert.assertNull(Oauth2Filter.parseErrorCode(body));
+  }
+
+  @Test
+  public void parseErrorCode_returnsNull_whenBodyIsNull() {
+    Assert.assertNull(Oauth2Filter.parseErrorCode(null));
+  }
+
+  @Test
+  public void parseErrorCode_returnsNull_whenBodyIsEmpty() {
+    Assert.assertNull(Oauth2Filter.parseErrorCode(""));
+  }
+
+  @Test
+  public void parseErrorCode_returnsNull_whenBodyIsNotJson() {
+    Assert.assertNull(Oauth2Filter.parseErrorCode("not json at all"));
+  }
+
+  @Test
+  public void parseErrorCode_returnsNull_whenBodyIsJsonArray() {
+    Assert.assertNull(Oauth2Filter.parseErrorCode("[1, 2, 3]"));
+  }
+
+  @Test
+  public void parseErrorCode_returnsNull_whenErrorCodeFieldIsJsonNull() {
+    String body = "{\"error\":\"invalid_token\",\"error_code\":null}";
+    Assert.assertNull(Oauth2Filter.parseErrorCode(body));
+  }
+}

+ 31 - 3
services/web/app/src/Features/Authentication/AuthenticationController.mjs

@@ -63,6 +63,31 @@ function checkCredentials(userDetailsMap, user, password) {
   return isValid
 }
 
+// Map a thrown @node-oauth/oauth2-server error to a stable, machine-readable
+// code that callers (e.g. git-bridge) can switch on. err.name values come
+// from the library's error classes (snake_case OAuth standard names per
+// RFC 6749/6750). The token_expired distinction is driven by a marker we
+// set ourselves in Oauth2ServerModel.getAccessToken, so it survives library
+// upgrades that might change error_description text.
+function _classifyOauthError(err) {
+  switch (err?.name) {
+    case 'invalid_token':
+      return err.overleafErrorCode === 'token_expired'
+        ? 'token_expired'
+        : 'token_invalid'
+    case 'invalid_request':
+      return err.overleafErrorCode === 'token_malformed'
+        ? 'token_malformed'
+        : 'invalid_request'
+    case 'insufficient_scope':
+      return 'insufficient_scope'
+    case 'unauthorized_request':
+      return 'unauthorized_request'
+    default:
+      return 'unknown'
+  }
+}
+
 // TODO: Finish making these methods async
 const AuthenticationController = {
   serializeUser(user, callback) {
@@ -400,11 +425,14 @@ const AuthenticationController = {
           err.message === 'Invalid request: malformed authorization header'
         ) {
           err.code = 401
+          err.overleafErrorCode = 'token_malformed'
         }
         // send all other errors
-        res
-          .status(err.code)
-          .json({ error: err.name, error_description: err.message })
+        res.status(err.code).json({
+          error: err.name,
+          error_description: err.message,
+          error_code: _classifyOauthError(err),
+        })
       }
     }
     return expressify(middleware)

+ 65 - 0
services/web/app/src/Features/Email/EmailBuilder.mjs

@@ -889,6 +889,71 @@ templates.securityAlert = NoCTAEmailTemplate({
   },
 })
 
+const GIT_TOKEN_DOCS_URL =
+  'https://docs.overleaf.com/integrations-and-add-ons/git-integration-and-github-synchronization/git-integration/git-integration-authentication-tokens#how-to-generate-authentication-tokens'
+
+templates.gitTokenExpiringSoon = NoCTAEmailTemplate({
+  subject() {
+    return 'Your Overleaf token is about to expire'
+  },
+  title() {
+    return 'Your token is about to expire'
+  },
+  greeting(opts) {
+    return opts.firstName ? `Hi ${opts.firstName},` : 'Hi,'
+  },
+  message(opts, isPlainText) {
+    const settingsLink = EmailMessageHelper.displayLink(
+      'account settings',
+      `${settings.siteUrl}/user/settings`,
+      isPlainText
+    )
+    const docsLink = EmailMessageHelper.displayLink(
+      'our docs',
+      GIT_TOKEN_DOCS_URL,
+      isPlainText
+    )
+    return [
+      `One of your Git authentication tokens is about to expire. This means you won't be able to use your token to authenticate when performing git operations.`,
+      `If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
+      `Take a look at ${docsLink} if you need more help.`,
+      'All the best,',
+      'Team Overleaf',
+    ]
+  },
+})
+
+templates.gitTokenExpired = NoCTAEmailTemplate({
+  subject() {
+    return 'Your Overleaf token has expired'
+  },
+  title() {
+    return 'Token expired'
+  },
+  greeting(opts) {
+    return opts.firstName ? `Hi ${opts.firstName},` : 'Hi,'
+  },
+  message(opts, isPlainText) {
+    const settingsLink = EmailMessageHelper.displayLink(
+      'account settings',
+      `${settings.siteUrl}/user/settings`,
+      isPlainText
+    )
+    const docsLink = EmailMessageHelper.displayLink(
+      'our docs',
+      GIT_TOKEN_DOCS_URL,
+      isPlainText
+    )
+    return [
+      `One of your Git authentication tokens has expired. This means you won't be able to use it to authenticate when performing git operations.`,
+      `If you haven't already, you'll need to generate a new token in your ${settingsLink}.`,
+      `Take a look at ${docsLink} if you need more help.`,
+      'All the best,',
+      'Team Overleaf',
+    ]
+  },
+})
+
 templates.SAMLDataCleared = ctaTemplate({
   subject(opts) {
     return `Institutional Login No Longer Linked - ${settings.appName}`

+ 8 - 0
services/web/app/src/models/OauthAccessToken.mjs

@@ -17,6 +17,14 @@ export const OauthAccessTokenSchema = new Schema(
     createdAt: { type: Date },
     expiresAt: Date,
     lastUsedAt: Date,
+    lastNotifiedAt: {
+      type: {
+        warning: Date,
+        expired: Date,
+      },
+      _id: false,
+    },
+    notificationsSuppressedAt: Date,
   },
   {
     collection: 'oauthAccessTokens',

+ 9 - 0
services/web/config/settings.defaults.js

@@ -426,6 +426,15 @@ module.exports = {
 
   // featuresEpoch: 'YYYY-MM-DD',
 
+  personalAccessTokens: {
+    expiry: {
+      warningWindowDays: intFromEnv(
+        'PERSONAL_ACCESS_TOKEN_WARNING_WINDOW_DAYS',
+        2
+      ),
+    },
+  },
+
   features: {
     personal: defaultFeatures,
   },

+ 5 - 0
services/web/frontend/extracted-translations.json

@@ -1281,6 +1281,7 @@
   "no_pdf_error_reason_output_pdf_already_exists": "",
   "no_pdf_error_reason_unrecoverable_error": "",
   "no_pdf_error_title": "",
+  "no_personal_access_tokens": "",
   "no_preview_available": "",
   "no_project_notifications_description": "",
   "no_projects": "",
@@ -1695,6 +1696,7 @@
   "save_x_or_more": "",
   "saving": "",
   "saving_notification_with_seconds": "",
+  "scope": "",
   "search": "",
   "search_all_project_files": "",
   "search_bib_files": "",
@@ -1914,6 +1916,7 @@
   "start_typing_find_your_company": "",
   "start_typing_find_your_organization": "",
   "start_typing_find_your_university": "",
+  "status": "",
   "stop": "",
   "stop_compile": "",
   "stop_on_first_error": "",
@@ -2098,6 +2101,7 @@
   "toggle_unknown_group": "",
   "token": "",
   "token_access_failure": "",
+  "token_expiring_soon": "",
   "token_generated": "",
   "token_limit_reached": "",
   "token_read_only": "",
@@ -2293,6 +2297,7 @@
   "user_has_left_organization_and_need_to_transfer_their_projects": "",
   "user_last_name_attribute": "",
   "user_management": "",
+  "user_personal_access_tokens": "",
   "user_sessions": "",
   "using_latex": "",
   "using_premium_features": "",

+ 5 - 0
services/web/locales/en.json

@@ -1675,6 +1675,7 @@
   "no_pdf_error_reason_output_pdf_already_exists": "This project contains a file called <code>output.pdf</code>. If that file exists, please rename it and compile again.",
   "no_pdf_error_reason_unrecoverable_error": "There is an unrecoverable LaTeX error. If there are LaTeX errors shown below or in the raw logs, please try to fix them and compile again.",
   "no_pdf_error_title": "No PDF",
+  "no_personal_access_tokens": "This user has no personal access tokens",
   "no_planned_maintenance": "There is currently no planned maintenance",
   "no_preview_available": "Sorry, no preview is available.",
   "no_project_notifications_description": "You won’t be notified about this project.",
@@ -2231,6 +2232,7 @@
   "save_x_or_more": "Save __percentage__ or more",
   "saving": "Saving",
   "saving_notification_with_seconds": "Saving __docname__... (__seconds__ seconds of unsaved changes)",
+  "scope": "Scope",
   "search": "Search",
   "search_all_project_files": "Search all project files",
   "search_bib_files": "Search by author, title, year",
@@ -2484,6 +2486,7 @@
   "start_typing_find_your_organization": "Start typing to find your organization",
   "start_typing_find_your_university": "Start typing to find your university",
   "state": "State",
+  "status": "Status",
   "status_checks": "Status Checks",
   "still_have_questions": "Still have questions?",
   "stop": "Stop",
@@ -2713,6 +2716,7 @@
   "toggle_unknown_group": "Toggle unknown group",
   "token": "Token",
   "token_access_failure": "Cannot grant access; contact the project owner for help",
+  "token_expiring_soon": "Expiring soon",
   "token_generated": "Token generated",
   "token_limit_reached": "You’ve reached the 10 token limit. To generate a new authentication token, please delete an existing one.",
   "token_read_only": "token read-only",
@@ -2937,6 +2941,7 @@
   "user_management": "User management",
   "user_metrics": "User metrics",
   "user_not_found": "User not found",
+  "user_personal_access_tokens": "Personal access tokens",
   "user_sessions": "User Sessions",
   "user_wants_you_to_see_project": "__username__ would like you to join __projectname__",
   "using_latex": "Using LaTeX",

+ 69 - 0
services/web/scripts/oauth/backfill_suppress_expired_token_notifications.mjs

@@ -0,0 +1,69 @@
+// One-off backfill: mark every personal access token that was already expired
+// at the moment the expiry-notification feature shipped, so the recurring
+// notifier (notify_expiring_tokens.mjs) does not blast a "your token has
+// expired" email to users about long-dead tokens.
+//
+// `notificationsSuppressedAt` is set ONLY by this script. It is intentionally
+// distinct from `lastNotifiedAt.expired` (which records actual sends) so the
+// two cases remain unambiguous in the data forever.
+//
+// Idempotent: re-running does nothing further once the flag is set.
+//
+// Pass --dry-run to count matching tokens without writing.
+
+import logger from '@overleaf/logger'
+import {
+  db,
+  READ_PREFERENCE_SECONDARY,
+} from '../../app/src/infrastructure/mongodb.mjs'
+import { scriptRunner } from '../lib/ScriptRunner.mjs'
+
+async function main() {
+  const dryRun = process.argv.includes('--dry-run')
+  const now = new Date()
+  const cursor = db.oauthAccessTokens.find(
+    {
+      type: 'pat',
+      accessTokenExpiresAt: { $lt: now },
+      'lastNotifiedAt.expired': { $exists: false },
+      notificationsSuppressedAt: { $exists: false },
+    },
+    {
+      projection: { _id: 1 },
+      readPreference: READ_PREFERENCE_SECONDARY,
+    }
+  )
+
+  let matched = 0
+  for await (const doc of cursor) {
+    if (!dryRun) {
+      await db.oauthAccessTokens.updateOne(
+        { _id: doc._id },
+        { $set: { notificationsSuppressedAt: now } }
+      )
+    }
+    matched++
+  }
+  if (dryRun) {
+    logger.info(
+      { matched },
+      'dry run: expired-token notifications would be suppressed'
+    )
+  } else {
+    logger.info(
+      { suppressed: matched },
+      'expired-token notifications suppressed'
+    )
+  }
+}
+
+try {
+  await scriptRunner(main)
+  process.exit(0)
+} catch (error) {
+  logger.error(
+    { err: error },
+    'backfill_suppress_expired_token_notifications failed'
+  )
+  process.exit(1)
+}

+ 138 - 0
services/web/scripts/oauth/notify_expiring_tokens.mjs

@@ -0,0 +1,138 @@
+// 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)
+  }
+}

+ 77 - 0
services/web/test/unit/src/Authentication/AuthenticationController.test.mjs

@@ -730,6 +730,83 @@ describe('AuthenticationController', function () {
         ctx.next.should.have.not.been.calledOnce
       })
     })
+
+    describe('error_code classification', function () {
+      // The classifier reads err.name (RFC-standard snake_case from
+      // @node-oauth/oauth2-server) plus an overleafErrorCode marker we
+      // attach ourselves. No reliance on err.message — that keeps the
+      // classification immune to library description changes.
+      async function runMiddlewareWithError(ctx, err) {
+        await new Promise(resolve => {
+          ctx.res.json.callsFake(() => resolve())
+          ctx.Oauth2Server.server.authenticate.rejects(err)
+          ctx.middleware(ctx.req, ctx.res, ctx.next)
+        })
+      }
+
+      it('returns "token_expired" when Oauth2ServerModel marks the error', async function (ctx) {
+        await runMiddlewareWithError(ctx, {
+          code: 401,
+          name: 'invalid_token',
+          overleafErrorCode: 'token_expired',
+        })
+        ctx.res.json.should.have.been.calledWithMatch({
+          error_code: 'token_expired',
+        })
+      })
+
+      it('returns "token_invalid" for an invalid_token error without a marker', async function (ctx) {
+        await runMiddlewareWithError(ctx, {
+          code: 401,
+          name: 'invalid_token',
+        })
+        ctx.res.json.should.have.been.calledWithMatch({
+          error_code: 'token_invalid',
+        })
+      })
+
+      it('returns "token_malformed" for a malformed authorization header', async function (ctx) {
+        await runMiddlewareWithError(ctx, {
+          code: 400,
+          name: 'invalid_request',
+          message: 'Invalid request: malformed authorization header',
+        })
+        ctx.res.json.should.have.been.calledWithMatch({
+          error_code: 'token_malformed',
+        })
+      })
+
+      it('returns "invalid_request" for any other invalid_request error', async function (ctx) {
+        await runMiddlewareWithError(ctx, {
+          code: 400,
+          name: 'invalid_request',
+          message: 'Invalid request: something else',
+        })
+        ctx.res.json.should.have.been.calledWithMatch({
+          error_code: 'invalid_request',
+        })
+      })
+
+      it('returns "insufficient_scope" for an insufficient_scope error', async function (ctx) {
+        await runMiddlewareWithError(ctx, {
+          code: 403,
+          name: 'insufficient_scope',
+        })
+        ctx.res.json.should.have.been.calledWithMatch({
+          error_code: 'insufficient_scope',
+        })
+      })
+
+      it('returns "unauthorized_request" for an unauthorized_request error', async function (ctx) {
+        await runMiddlewareWithError(ctx, {
+          code: 401,
+          name: 'unauthorized_request',
+        })
+        ctx.res.json.should.have.been.calledWithMatch({
+          error_code: 'unauthorized_request',
+        })
+      })
+    })
   })
 
   describe('requireGlobalLogin', function () {

+ 44 - 0
services/web/test/unit/src/Email/EmailBuilder.test.mjs

@@ -173,6 +173,50 @@ describe('EmailBuilder', function () {
     })
   })
 
+  describe('gitTokenExpiringSoon', function () {
+    beforeEach(function (ctx) {
+      ctx.opts = {
+        to: 'user@example.com',
+      }
+      ctx.email = ctx.EmailBuilder.buildEmail('gitTokenExpiringSoon', ctx.opts)
+    })
+
+    it('should render html, text, and subject without undefined', function (ctx) {
+      expect(ctx.email.html).to.not.be.undefined
+      expect(ctx.email.text).to.not.be.undefined
+      expect(ctx.email.subject).to.not.be.undefined
+      ctx.email.html.indexOf('undefined').should.equal(-1)
+      ctx.email.text.indexOf('undefined').should.equal(-1)
+      ctx.email.subject.indexOf('undefined').should.equal(-1)
+    })
+
+    it('should link the CTA to user settings', function (ctx) {
+      ctx.email.text.should.contain(`${ctx.settings.siteUrl}/user/settings`)
+    })
+  })
+
+  describe('gitTokenExpired', function () {
+    beforeEach(function (ctx) {
+      ctx.opts = {
+        to: 'user@example.com',
+      }
+      ctx.email = ctx.EmailBuilder.buildEmail('gitTokenExpired', ctx.opts)
+    })
+
+    it('should render html, text, and subject without undefined', function (ctx) {
+      expect(ctx.email.html).to.not.be.undefined
+      expect(ctx.email.text).to.not.be.undefined
+      expect(ctx.email.subject).to.not.be.undefined
+      ctx.email.html.indexOf('undefined').should.equal(-1)
+      ctx.email.text.indexOf('undefined').should.equal(-1)
+      ctx.email.subject.indexOf('undefined').should.equal(-1)
+    })
+
+    it('should link the CTA to user settings', function (ctx) {
+      ctx.email.text.should.contain(`${ctx.settings.siteUrl}/user/settings`)
+    })
+  })
+
   describe('ctaTemplate', function () {
     describe('missing required content', function () {
       const content = {

+ 213 - 0
services/web/test/unit/src/Scripts/notify_expiring_tokens.test.mjs

@@ -0,0 +1,213 @@
+import { vi, expect } from 'vitest'
+import sinon from 'sinon'
+
+const SCRIPT_PATH = '../../../../scripts/oauth/notify_expiring_tokens.mjs'
+
+describe('notify_expiring_tokens', function () {
+  beforeEach(async function (ctx) {
+    ctx.userEmail = 'user@example.com'
+
+    ctx.User = {
+      findOne: sinon.stub().returns({
+        exec: sinon.stub().resolves({ email: ctx.userEmail }),
+      }),
+    }
+
+    ctx.collection = {
+      cursor: [],
+      find: sinon.stub().callsFake(() => ({
+        [Symbol.asyncIterator]: async function* () {
+          for (const t of ctx.collection.cursor) yield t
+        },
+      })),
+      updateOne: sinon.stub().resolves({ modifiedCount: 1 }),
+    }
+
+    ctx.EmailHandler = {
+      promises: {
+        sendEmail: sinon.stub().resolves(),
+      },
+    }
+
+    vi.doMock('../../../../app/src/infrastructure/mongodb.mjs', () => ({
+      db: { oauthAccessTokens: ctx.collection },
+      READ_PREFERENCE_SECONDARY: 'secondary',
+    }))
+
+    vi.doMock('../../../../app/src/models/User.mjs', () => ({
+      User: ctx.User,
+    }))
+
+    vi.doMock('../../../../app/src/Features/Email/EmailHandler.mjs', () => ({
+      default: ctx.EmailHandler,
+    }))
+
+    vi.doMock('@overleaf/settings', () => ({
+      default: {
+        personalAccessTokens: { expiry: { warningWindowDays: 2 } },
+      },
+    }))
+
+    vi.doMock('../../../../scripts/lib/ScriptRunner.mjs', () => ({
+      scriptRunner: async fn => fn(),
+    }))
+
+    ctx.script = await import(SCRIPT_PATH)
+  })
+
+  describe('notifyOwner', function () {
+    it('returns true and sets lastNotifiedAt on successful send', async function (ctx) {
+      const token = {
+        _id: 'tok-1',
+        user_id: 'user-1',
+        accessTokenExpiresAt: new Date('2026-05-20T00:00:00Z'),
+      }
+      const ok = await ctx.script.notifyOwner({
+        token,
+        kind: 'warning',
+        template: 'gitTokenExpiringSoon',
+      })
+      expect(ok).to.equal(true)
+      expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
+        'gitTokenExpiringSoon',
+        sinon.match({ to: ctx.userEmail })
+      )
+      expect(ctx.collection.updateOne).to.have.been.calledOnce
+      const update = ctx.collection.updateOne.firstCall.args[1]
+      expect(update.$set).to.have.property('lastNotifiedAt.warning')
+    })
+
+    it('returns false and does NOT set the marker if EmailHandler rejects', async function (ctx) {
+      ctx.EmailHandler.promises.sendEmail.rejects(new Error('SMTP down'))
+      const token = {
+        _id: 'tok-2',
+        user_id: 'user-2',
+        accessTokenExpiresAt: new Date('2026-05-20T00:00:00Z'),
+      }
+      const ok = await ctx.script.notifyOwner({
+        token,
+        kind: 'expired',
+        template: 'gitTokenExpired',
+      })
+      expect(ok).to.equal(false)
+      expect(ctx.collection.updateOne).to.not.have.been.called
+    })
+
+    it('returns false and skips when the owner has no email', async function (ctx) {
+      ctx.User.findOne.returns({
+        exec: sinon.stub().resolves(null),
+      })
+      const token = {
+        _id: 'tok-3',
+        user_id: 'user-3',
+        accessTokenExpiresAt: new Date('2026-05-20T00:00:00Z'),
+      }
+      const ok = await ctx.script.notifyOwner({
+        token,
+        kind: 'warning',
+        template: 'gitTokenExpiringSoon',
+      })
+      expect(ok).to.equal(false)
+      expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
+      expect(ctx.collection.updateOne).to.not.have.been.called
+    })
+  })
+
+  describe('processBucket', function () {
+    it('iterates all matching tokens and counts successful sends', async function (ctx) {
+      ctx.collection.cursor = [
+        {
+          _id: 't1',
+          user_id: 'u1',
+          accessTokenExpiresAt: new Date('2026-05-20T00:00:00Z'),
+        },
+        {
+          _id: 't2',
+          user_id: 'u2',
+          accessTokenExpiresAt: new Date('2026-05-21T00:00:00Z'),
+        },
+      ]
+      const count = await ctx.script.processBucket({
+        kind: 'warning',
+        template: 'gitTokenExpiringSoon',
+        query: { type: 'pat' },
+      })
+      expect(count).to.equal(2)
+      expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledTwice
+      expect(ctx.collection.updateOne).to.have.been.calledTwice
+    })
+
+    it('continues processing remaining tokens after one send fails', async function (ctx) {
+      ctx.collection.cursor = [
+        {
+          _id: 't1',
+          user_id: 'u1',
+          accessTokenExpiresAt: new Date('2026-05-20T00:00:00Z'),
+        },
+        {
+          _id: 't2',
+          user_id: 'u2',
+          accessTokenExpiresAt: new Date('2026-05-21T00:00:00Z'),
+        },
+      ]
+      ctx.EmailHandler.promises.sendEmail
+        .onFirstCall()
+        .rejects(new Error('transient'))
+        .onSecondCall()
+        .resolves()
+
+      const count = await ctx.script.processBucket({
+        kind: 'expired',
+        template: 'gitTokenExpired',
+        query: { type: 'pat' },
+      })
+      expect(count).to.equal(1)
+      expect(ctx.collection.updateOne).to.have.been.calledOnce
+    })
+
+    it('returns 0 when no tokens match', async function (ctx) {
+      ctx.collection.cursor = []
+      const count = await ctx.script.processBucket({
+        kind: 'warning',
+        template: 'gitTokenExpiringSoon',
+        query: { type: 'pat' },
+      })
+      expect(count).to.equal(0)
+      expect(ctx.EmailHandler.promises.sendEmail).to.not.have.been.called
+    })
+  })
+
+  describe('main query construction', function () {
+    it('queries the warning bucket within the configured window and skips already-warned tokens', async function (ctx) {
+      ctx.collection.cursor = []
+      await ctx.script.main()
+
+      const warningCall = ctx.collection.find
+        .getCalls()
+        .find(c => c.args[0]['lastNotifiedAt.warning'])
+      expect(warningCall).to.exist
+      const q = warningCall.args[0]
+      expect(q.type).to.equal('pat')
+      expect(q['lastNotifiedAt.warning']).to.deep.equal({ $exists: false })
+      expect(q.accessTokenExpiresAt.$gt).to.be.instanceOf(Date)
+      expect(q.accessTokenExpiresAt.$lte).to.be.instanceOf(Date)
+      const horizonMs =
+        q.accessTokenExpiresAt.$lte.getTime() -
+        q.accessTokenExpiresAt.$gt.getTime()
+      expect(horizonMs).to.equal(2 * 24 * 60 * 60 * 1000)
+    })
+
+    it('queries the expired bucket and excludes suppressed tokens', async function (ctx) {
+      ctx.collection.cursor = []
+      await ctx.script.main()
+
+      const expiredCall = ctx.collection.find
+        .getCalls()
+        .find(c => c.args[0]['lastNotifiedAt.expired'])
+      expect(expiredCall).to.exist
+      const q = expiredCall.args[0]
+      expect(q['lastNotifiedAt.expired']).to.deep.equal({ $exists: false })
+      expect(q.notificationsSuppressedAt).to.deep.equal({ $exists: false })
+    })
+  })
+})