Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 4ef876f

Browse files
authored
fix(auth): Log out on rejected refresh token instead of stalling startup (#2812)
1 parent 974a083 commit 4ef876f

2 files changed

Lines changed: 69 additions & 3 deletions

File tree

packages/core/src/oauth/oauth.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,53 @@ describe("OAuthService.refreshToken", () => {
122122
expect(result.errorCode).toBe("auth_error");
123123
});
124124

125+
it.each([
126+
{
127+
name: "invalid_grant",
128+
body: { error: "invalid_grant" },
129+
expected: "auth_error",
130+
},
131+
{
132+
name: "invalid_token",
133+
body: { error: "invalid_token" },
134+
expected: "auth_error",
135+
},
136+
{
137+
name: "invalid_client",
138+
body: { error: "invalid_client" },
139+
expected: "unknown_error",
140+
},
141+
{
142+
name: "invalid_request",
143+
body: { error: "invalid_request" },
144+
expected: "unknown_error",
145+
},
146+
{
147+
name: "a non-string error field",
148+
body: { error: 42 },
149+
expected: "unknown_error",
150+
},
151+
{ name: "no error field", body: {}, expected: "unknown_error" },
152+
])("maps a 400 $name to a $expected", async ({ body, expected }) => {
153+
const { service } = createDeps();
154+
fetchMock.mockResolvedValue(jsonResponse(body, 400));
155+
156+
const result = await service.refreshToken("rt", "us");
157+
158+
expect(result.success).toBe(false);
159+
expect(result.errorCode).toBe(expected);
160+
});
161+
162+
it("maps a 400 with an unparseable body to an unknown_error", async () => {
163+
const { service } = createDeps();
164+
fetchMock.mockResolvedValue(new Response("", { status: 400 }));
165+
166+
const result = await service.refreshToken("rt", "us");
167+
168+
expect(result.success).toBe(false);
169+
expect(result.errorCode).toBe("unknown_error");
170+
});
171+
125172
it("maps 5xx to a server_error", async () => {
126173
const { service } = createDeps();
127174
fetchMock.mockResolvedValue(jsonResponse({}, 503));
@@ -133,7 +180,7 @@ describe("OAuthService.refreshToken", () => {
133180

134181
it("maps other 4xx to an unknown_error", async () => {
135182
const { service } = createDeps();
136-
fetchMock.mockResolvedValue(jsonResponse({}, 400));
183+
fetchMock.mockResolvedValue(jsonResponse({}, 404));
137184

138185
const result = await service.refreshToken("rt", "us");
139186

packages/core/src/oauth/oauth.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ interface PendingOAuthFlow {
6161
abortController?: AbortController;
6262
}
6363

64+
async function parseOAuthErrorCode(response: Response): Promise<string | null> {
65+
try {
66+
const body = (await response.json()) as { error?: unknown };
67+
return typeof body.error === "string" ? body.error : null;
68+
} catch {
69+
return null;
70+
}
71+
}
72+
6473
@injectable()
6574
export class OAuthService {
6675
private pendingFlow: PendingOAuthFlow | null = null;
@@ -216,8 +225,18 @@ export class OAuthService {
216225
});
217226

218227
if (!response.ok) {
219-
// 401/403 are auth errors - the token is invalid
220-
const isAuthError = response.status === 401 || response.status === 403;
228+
// 401/403 are always auth failures. A 400 is only a dead refresh token
229+
// when the OAuth error is invalid_grant/invalid_token; other 400s like
230+
// invalid_client or invalid_request are config bugs and must not log the
231+
// user out, or they would be unable to log back in with the same broken
232+
// config.
233+
const oauthErrorCode =
234+
response.status === 400 ? await parseOAuthErrorCode(response) : null;
235+
const isAuthError =
236+
response.status === 401 ||
237+
response.status === 403 ||
238+
oauthErrorCode === "invalid_grant" ||
239+
oauthErrorCode === "invalid_token";
221240
// 5xx are server errors - should be retried
222241
const isServerError = response.status >= 500;
223242
this.log.warn(

0 commit comments

Comments
 (0)