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
This commit is contained in:
@@ -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 => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -426,6 +426,15 @@ module.exports = {
|
||||
|
||||
// featuresEpoch: 'YYYY-MM-DD',
|
||||
|
||||
personalAccessTokens: {
|
||||
expiry: {
|
||||
warningWindowDays: intFromEnv(
|
||||
'PERSONAL_ACCESS_TOKEN_WARNING_WINDOW_DAYS',
|
||||
2
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
features: {
|
||||
personal: defaultFeatures,
|
||||
},
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user