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:
Liam O'Brien
2026-06-04 08:05:26 +00:00
committed by Copybot
co-authored by Claude Opus 4.7
parent a553a8390d
commit e53c6f2aea
14 changed files with 778 additions and 10 deletions
@@ -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));
}
}