diff --git a/server-ce/test/admin.spec.ts b/server-ce/test/admin.spec.ts index eeedbf75dd..1c3c56a522 100644 --- a/server-ce/test/admin.spec.ts +++ b/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 => { diff --git a/services/git-bridge/src/main/java/uk/ac/ic/wlgitbridge/server/Oauth2Filter.java b/services/git-bridge/src/main/java/uk/ac/ic/wlgitbridge/server/Oauth2Filter.java index c2a279f150..4d61a7fc0b 100644 --- a/services/git-bridge/src/main/java/uk/ac/ic/wlgitbridge/server/Oauth2Filter.java +++ b/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( diff --git a/services/git-bridge/src/test/java/uk/ac/ic/wlgitbridge/server/Oauth2FilterTest.java b/services/git-bridge/src/test/java/uk/ac/ic/wlgitbridge/server/Oauth2FilterTest.java new file mode 100644 index 0000000000..9e4aaa1406 --- /dev/null +++ b/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)); + } +} diff --git a/services/web/app/src/Features/Authentication/AuthenticationController.mjs b/services/web/app/src/Features/Authentication/AuthenticationController.mjs index 8ef0712a37..66e005f0d6 100644 --- a/services/web/app/src/Features/Authentication/AuthenticationController.mjs +++ b/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) diff --git a/services/web/app/src/Features/Email/EmailBuilder.mjs b/services/web/app/src/Features/Email/EmailBuilder.mjs index 21a1314b08..b93d37c8b3 100644 --- a/services/web/app/src/Features/Email/EmailBuilder.mjs +++ b/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}` diff --git a/services/web/app/src/models/OauthAccessToken.mjs b/services/web/app/src/models/OauthAccessToken.mjs index 3cc2a22058..a30c7d16dd 100644 --- a/services/web/app/src/models/OauthAccessToken.mjs +++ b/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', diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index bd1da3fd39..aa5c10965a 100644 --- a/services/web/config/settings.defaults.js +++ b/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, }, diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index ff696cbae7..be2b05a1fc 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index e28c77692c..64972f09e1 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -1675,6 +1675,7 @@ "no_pdf_error_reason_output_pdf_already_exists": "This project contains a file called output.pdf. 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", diff --git a/services/web/scripts/oauth/backfill_suppress_expired_token_notifications.mjs b/services/web/scripts/oauth/backfill_suppress_expired_token_notifications.mjs new file mode 100644 index 0000000000..e35ed5198c --- /dev/null +++ b/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) +} diff --git a/services/web/scripts/oauth/notify_expiring_tokens.mjs b/services/web/scripts/oauth/notify_expiring_tokens.mjs new file mode 100644 index 0000000000..ca43894f98 --- /dev/null +++ b/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) + } +} diff --git a/services/web/test/unit/src/Authentication/AuthenticationController.test.mjs b/services/web/test/unit/src/Authentication/AuthenticationController.test.mjs index 0edfa4f002..bafac07c38 100644 --- a/services/web/test/unit/src/Authentication/AuthenticationController.test.mjs +++ b/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 () { diff --git a/services/web/test/unit/src/Email/EmailBuilder.test.mjs b/services/web/test/unit/src/Email/EmailBuilder.test.mjs index 8b94fcab5d..4d376ab264 100644 --- a/services/web/test/unit/src/Email/EmailBuilder.test.mjs +++ b/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 = { diff --git a/services/web/test/unit/src/Scripts/notify_expiring_tokens.test.mjs b/services/web/test/unit/src/Scripts/notify_expiring_tokens.test.mjs new file mode 100644 index 0000000000..14095e8277 --- /dev/null +++ b/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 }) + }) + }) +})