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

Commit 78d57ae

Browse files
authored
fix(mobile): only log out on a genuinely rejected refresh token (port #2812)
Mobile had its own OAuth refresh path that threw a generic Error on any non-ok response, so a transient network/server blip at startup deleted the tokens and signed the user out. Classify the refresh failure in `refreshAccessToken` and only sign out when the refresh token is genuinely dead: - 401/403, or 400 with `invalid_grant`/`invalid_token` -> auth_error -> sign out - 5xx -> server_error -> keep session - network failure -> network_error -> keep session - other 400s (e.g. invalid_client) -> unknown_error/config -> keep session `initializeAuth` now keeps the stored session on transient/config failures so the per-request `authedFetch` retry can recover, and only deletes tokens on a genuine auth_error. Ports desktop #2812 to apps/mobile. Generated-By: PostHog Code Task-Id: c274cae6-c3d7-46f4-bfe1-96b3514dbc94
1 parent 0d5571d commit 78d57ae

4 files changed

Lines changed: 254 additions & 45 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("expo-auth-session", () => ({
4+
makeRedirectUri: () => "posthog://callback",
5+
AuthRequest: class {},
6+
}));
7+
8+
vi.mock("expo-web-browser", () => ({
9+
maybeCompleteAuthSession: () => {},
10+
}));
11+
12+
import { refreshAccessToken, TokenRefreshError } from "./oauth";
13+
14+
const originalFetch = global.fetch;
15+
16+
function mockResponse(status: number, body: unknown): Response {
17+
return new Response(JSON.stringify(body), {
18+
status,
19+
statusText: `Status ${status}`,
20+
headers: { "Content-Type": "application/json" },
21+
});
22+
}
23+
24+
describe("refreshAccessToken", () => {
25+
beforeEach(() => {
26+
global.fetch = vi.fn() as unknown as typeof fetch;
27+
});
28+
29+
afterEach(() => {
30+
global.fetch = originalFetch;
31+
});
32+
33+
it("returns the parsed token response on success", async () => {
34+
vi.mocked(global.fetch).mockResolvedValueOnce(
35+
mockResponse(200, { access_token: "fresh", expires_in: 3600 }),
36+
);
37+
38+
const result = await refreshAccessToken("refresh", "us");
39+
40+
expect(result.access_token).toBe("fresh");
41+
});
42+
43+
it.each([
44+
{ name: "401", status: 401, body: {} },
45+
{ name: "403", status: 403, body: {} },
46+
{
47+
name: "400 invalid_grant",
48+
status: 400,
49+
body: { error: "invalid_grant" },
50+
},
51+
{
52+
name: "400 invalid_token",
53+
status: 400,
54+
body: { error: "invalid_token" },
55+
},
56+
])("classifies $name as auth_error", async ({ status, body }) => {
57+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse(status, body));
58+
59+
await expect(refreshAccessToken("refresh", "us")).rejects.toMatchObject({
60+
errorCode: "auth_error",
61+
});
62+
});
63+
64+
it.each([
65+
{ name: "invalid_client", body: { error: "invalid_client" } },
66+
{ name: "invalid_request", body: { error: "invalid_request" } },
67+
])("classifies a 400 $name as unknown_error", async ({ body }) => {
68+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse(400, body));
69+
70+
await expect(refreshAccessToken("refresh", "us")).rejects.toMatchObject({
71+
errorCode: "unknown_error",
72+
});
73+
});
74+
75+
it.each([500, 502, 503])(
76+
"classifies a %i as server_error",
77+
async (status) => {
78+
vi.mocked(global.fetch).mockResolvedValueOnce(mockResponse(status, {}));
79+
80+
await expect(refreshAccessToken("refresh", "us")).rejects.toMatchObject({
81+
errorCode: "server_error",
82+
});
83+
},
84+
);
85+
86+
it("classifies a thrown fetch as network_error", async () => {
87+
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("offline"));
88+
89+
const error = await refreshAccessToken("refresh", "us").catch((e) => e);
90+
91+
expect(error).toBeInstanceOf(TokenRefreshError);
92+
expect(error.errorCode).toBe("network_error");
93+
});
94+
});

apps/mobile/src/features/auth/lib/oauth.ts

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,26 +53,78 @@ export async function exchangeCodeForToken(
5353
return response.json();
5454
}
5555

56+
export type OAuthRefreshErrorCode =
57+
| "auth_error"
58+
| "server_error"
59+
| "network_error"
60+
| "unknown_error";
61+
62+
export class TokenRefreshError extends Error {
63+
readonly errorCode: OAuthRefreshErrorCode;
64+
65+
constructor(errorCode: OAuthRefreshErrorCode, message: string) {
66+
super(message);
67+
this.name = "TokenRefreshError";
68+
this.errorCode = errorCode;
69+
}
70+
}
71+
72+
async function parseOAuthErrorCode(response: Response): Promise<string | null> {
73+
try {
74+
const body = (await response.json()) as { error?: unknown };
75+
return typeof body.error === "string" ? body.error : null;
76+
} catch {
77+
return null;
78+
}
79+
}
80+
5681
export async function refreshAccessToken(
5782
refreshToken: string,
5883
region: CloudRegion,
5984
): Promise<OAuthTokenResponse> {
6085
const cloudUrl = getCloudUrlFromRegion(region);
6186

62-
const response = await fetch(`${cloudUrl}/oauth/token`, {
63-
method: "POST",
64-
headers: {
65-
"Content-Type": "application/json",
66-
},
67-
body: JSON.stringify({
68-
grant_type: "refresh_token",
69-
refresh_token: refreshToken,
70-
client_id: getOauthClientIdFromRegion(region),
71-
}),
72-
});
87+
let response: Response;
88+
try {
89+
response = await fetch(`${cloudUrl}/oauth/token`, {
90+
method: "POST",
91+
headers: {
92+
"Content-Type": "application/json",
93+
},
94+
body: JSON.stringify({
95+
grant_type: "refresh_token",
96+
refresh_token: refreshToken,
97+
client_id: getOauthClientIdFromRegion(region),
98+
}),
99+
});
100+
} catch (error) {
101+
throw new TokenRefreshError(
102+
"network_error",
103+
error instanceof Error ? error.message : "Token refresh network error",
104+
);
105+
}
73106

74107
if (!response.ok) {
75-
throw new Error(`Token refresh failed: ${response.statusText}`);
108+
// 401/403 are always auth failures. A 400 only means a dead refresh token
109+
// when the OAuth error is invalid_grant/invalid_token; other 400s like
110+
// invalid_client are config bugs that must not sign the user out, or they
111+
// could never log back in with the same broken config.
112+
const oauthErrorCode =
113+
response.status === 400 ? await parseOAuthErrorCode(response) : null;
114+
const isAuthError =
115+
response.status === 401 ||
116+
response.status === 403 ||
117+
oauthErrorCode === "invalid_grant" ||
118+
oauthErrorCode === "invalid_token";
119+
const errorCode: OAuthRefreshErrorCode = isAuthError
120+
? "auth_error"
121+
: response.status >= 500
122+
? "server_error"
123+
: "unknown_error";
124+
throw new TokenRefreshError(
125+
errorCode,
126+
`Token refresh failed: ${response.status} ${response.statusText}`,
127+
);
76128
}
77129

78130
return response.json();

apps/mobile/src/features/auth/stores/authStore.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,13 @@ vi.mock("@react-native-async-storage/async-storage", () => ({
3131
vi.mock("../lib/oauth", () => ({
3232
performOAuthFlow: mockPerformOAuthFlow,
3333
refreshAccessToken: mockRefreshAccessTokenRequest,
34+
TokenRefreshError: class TokenRefreshError extends Error {
35+
readonly errorCode: string;
36+
constructor(errorCode: string, message: string) {
37+
super(message);
38+
this.errorCode = errorCode;
39+
}
40+
},
3441
}));
3542

3643
vi.mock("../lib/secureStorage", () => ({
@@ -63,8 +70,20 @@ vi.mock("@/lib/queryClient", () => ({
6370
}));
6471

6572
import { OAUTH_SCOPE_VERSION } from "../lib/constants";
73+
import { TokenRefreshError } from "../lib/oauth";
6674
import { useAuthStore } from "./authStore";
6775

76+
function expiredStoredTokens() {
77+
return {
78+
accessToken: "old-token",
79+
refreshToken: "old-refresh",
80+
expiresAt: Date.now() - 1_000,
81+
cloudRegion: "us" as const,
82+
scopedTeams: [42],
83+
scopeVersion: OAUTH_SCOPE_VERSION,
84+
};
85+
}
86+
6887
describe("authStore", () => {
6988
beforeEach(() => {
7089
mockPerformOAuthFlow.mockReset();
@@ -135,4 +154,51 @@ describe("authStore", () => {
135154
isLoading: false,
136155
});
137156
});
157+
158+
it("signs out when refreshing an expired token is rejected as auth_error", async () => {
159+
mockGetTokens.mockResolvedValueOnce(expiredStoredTokens());
160+
mockRefreshAccessTokenRequest.mockRejectedValueOnce(
161+
new TokenRefreshError("auth_error", "invalid_grant"),
162+
);
163+
164+
const initialized = await useAuthStore.getState().initializeAuth();
165+
166+
expect(initialized).toBe(false);
167+
expect(mockDeleteTokens).toHaveBeenCalledOnce();
168+
expect(useAuthStore.getState()).toMatchObject({
169+
isAuthenticated: false,
170+
isLoading: false,
171+
});
172+
});
173+
174+
it.each([
175+
{
176+
name: "server_error",
177+
error: new TokenRefreshError("server_error", "5xx"),
178+
},
179+
{
180+
name: "network_error",
181+
error: new TokenRefreshError("network_error", "offline"),
182+
},
183+
{
184+
name: "unknown_error (config 400)",
185+
error: new TokenRefreshError("unknown_error", "invalid_client"),
186+
},
187+
])(
188+
"keeps the session when an expired-token refresh fails with $name",
189+
async ({ error }) => {
190+
mockGetTokens.mockResolvedValueOnce(expiredStoredTokens());
191+
mockRefreshAccessTokenRequest.mockRejectedValueOnce(error);
192+
193+
const initialized = await useAuthStore.getState().initializeAuth();
194+
195+
expect(initialized).toBe(true);
196+
expect(mockDeleteTokens).not.toHaveBeenCalled();
197+
expect(useAuthStore.getState()).toMatchObject({
198+
oauthAccessToken: "old-token",
199+
isAuthenticated: true,
200+
isLoading: false,
201+
});
202+
},
203+
);
138204
});

apps/mobile/src/features/auth/stores/authStore.ts

Lines changed: 30 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import {
1515
performOAuthFlow,
1616
refreshAccessToken as refreshAccessTokenRequest,
17+
TokenRefreshError,
1718
} from "../lib/oauth";
1819
import { deleteTokens, getTokens, saveTokens } from "../lib/secureStorage";
1920
import type { CloudRegion, StoredTokens } from "../types";
@@ -78,6 +79,20 @@ function resolveActiveProjectId(
7879
return scopedTeams[0] ?? null;
7980
}
8081

82+
function isDeadRefreshToken(error: unknown): boolean {
83+
return error instanceof TokenRefreshError && error.errorCode === "auth_error";
84+
}
85+
86+
const CLEARED_AUTH_STATE = {
87+
oauthAccessToken: null,
88+
oauthRefreshToken: null,
89+
tokenExpiry: null,
90+
cloudRegion: null,
91+
projectId: null,
92+
scopedTeams: [],
93+
isAuthenticated: false,
94+
} satisfies Partial<AuthState>;
95+
8196
function maybeRegisterPushToken(): void {
8297
if (!usePreferencesStore.getState().pushNotificationsEnabled) return;
8398
usePushTokenStore
@@ -282,16 +297,7 @@ export const useAuthStore = create<AuthState>()(
282297
if (tokens.scopeVersion !== OAUTH_SCOPE_VERSION) {
283298
await deleteTokens();
284299
queryClient.clear();
285-
set({
286-
oauthAccessToken: null,
287-
oauthRefreshToken: null,
288-
tokenExpiry: null,
289-
cloudRegion: null,
290-
projectId: null,
291-
scopedTeams: [],
292-
isLoading: false,
293-
isAuthenticated: false,
294-
});
300+
set({ ...CLEARED_AUTH_STATE, isLoading: false });
295301
return false;
296302
}
297303

@@ -320,20 +326,19 @@ export const useAuthStore = create<AuthState>()(
320326
try {
321327
await get().refreshAccessToken();
322328
} catch (error) {
323-
logger.error("Failed to refresh expired token:", error);
324-
await deleteTokens();
325-
queryClient.clear();
326-
set({
327-
oauthAccessToken: null,
328-
oauthRefreshToken: null,
329-
tokenExpiry: null,
330-
cloudRegion: null,
331-
projectId: null,
332-
scopedTeams: [],
333-
isLoading: false,
334-
isAuthenticated: false,
335-
});
336-
return false;
329+
if (isDeadRefreshToken(error)) {
330+
logger.error("Refresh token rejected on startup; signing out");
331+
await deleteTokens();
332+
queryClient.clear();
333+
set({ ...CLEARED_AUTH_STATE, isLoading: false });
334+
return false;
335+
}
336+
// Transient (network/server) or config failure: keep the stored
337+
// session so the next request's authedFetch retry can recover.
338+
logger.warn(
339+
"Token refresh failed transiently on startup; keeping session",
340+
error,
341+
);
337342
}
338343
}
339344

@@ -362,15 +367,7 @@ export const useAuthStore = create<AuthState>()(
362367
// Clear React Query cache to prevent data leakage between sessions
363368
queryClient.clear();
364369

365-
set({
366-
oauthAccessToken: null,
367-
oauthRefreshToken: null,
368-
tokenExpiry: null,
369-
cloudRegion: null,
370-
projectId: null,
371-
scopedTeams: [],
372-
isAuthenticated: false,
373-
});
370+
set({ ...CLEARED_AUTH_STATE });
374371
},
375372
}),
376373
{

0 commit comments

Comments
 (0)