Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/network-error-logout-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"authkit-react-native": patch
---

Fix random logouts on unstable network connections. Token refresh failures now only clear the session when the server rejects the refresh token (`TokenError`, e.g. `invalid_grant`); transient failures such as network errors keep the session intact so a later call can retry, and return the stored access token when it is still fresh. Concurrent `getAccessToken()` calls now share a single in-flight refresh, preventing parallel refreshes from racing on the single-use refresh token and signing the user out. `signOut()` now always clears the local session even if the revocation request fails offline.
4 changes: 3 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ Examples are self-contained with their own `package.json`. They duplicate server
- **No React provider**: Zustand store lives outside React, so there's no context provider to wrap the app in. This also means session restoration starts before the first render.
- **Module-level side effects**: `WebBrowser.maybeCompleteAuthSession()` runs at import time (required by expo-auth-session to complete the OAuth redirect). `WebBrowser.warmUpAsync()` runs at store creation for faster sign-in.
- **Peer dependencies only**: All Expo modules and Zustand are peer deps. The package has zero direct dependencies.
- **`emptySession` constant**: Used in `signOut`, `getAccessToken` (no-token path), and `getAccessToken` (error path) to clear auth state consistently.
- **`emptySession` constant**: Used in `signOut`, `getAccessToken` (no-token path), and `getAccessToken` (server-rejection path) to clear auth state consistently.
- **Network errors don't clear the session**: `getAccessToken` only signs the user out when the server rejects the refresh (`TokenError`, e.g. `invalid_grant`). Transient failures (offline, server hiccups) keep tokens and state so a later call retries — returning the stored access token if still fresh. Conversely, `signOut` treats revocation as best-effort and always clears locally, even offline.
- **Single-flight refresh**: concurrent `getAccessToken` calls share one in-flight refresh request. WorkOS refresh tokens are single-use, so parallel refreshes would race and the loser's `invalid_grant` would be indistinguishable from a revoked session.

## Build

Expand Down
207 changes: 125 additions & 82 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
makeRedirectUri,
RefreshTokenRequest,
RevokeTokenRequest,
TokenError,
TokenResponse,
} from "expo-auth-session";
import * as WebBrowser from "expo-web-browser";
Expand Down Expand Up @@ -79,102 +80,144 @@ export function createAuthStore(config: AuthConfig) {
return meta;
}

const store = createStore<AuthState>()((set) => ({
isLoading: true,
const store = createStore<AuthState>()((set) => {
// Single-flight refresh: concurrent getAccessToken calls share one
// in-flight request. WorkOS refresh tokens are single-use, so parallel
// refreshes race and the loser's invalid_grant is indistinguishable
// from a revoked session.
let refreshInFlight: Promise<string> | null = null;

function refreshSession(tokenResponse: TokenResponse): Promise<string> {
if (!refreshInFlight) {
refreshInFlight = (async () => {
try {
const refreshTokenRequest = new RefreshTokenRequest({
refreshToken: tokenResponse.refreshToken,
clientId,
});

const refreshed = await refreshTokenRequest.performAsync({
tokenEndpoint,
});

const meta = await storeTokenResponse(refreshed);
set(meta);
return refreshed.accessToken;
} finally {
refreshInFlight = null;
}
})();
}
return refreshInFlight;
}

signIn: async ({ screenHint = "sign-in" } = {}) => {
const authSessionRequest = new AuthRequest({
clientId,
redirectUri,
extraParams: { provider: "authkit", screen_hint: screenHint },
});
return {
isLoading: true,

const authSessionResult = await authSessionRequest.promptAsync({
authorizationEndpoint,
});
signIn: async ({ screenHint = "sign-in" } = {}) => {
const authSessionRequest = new AuthRequest({
clientId,
redirectUri,
extraParams: { provider: "authkit", screen_hint: screenHint },
});

if (authSessionResult.type === "error") {
throw new Error(
authSessionResult.error?.description ?? "Unknown error",
);
}
const authSessionResult = await authSessionRequest.promptAsync({
authorizationEndpoint,
});

if (authSessionResult.type !== "success") {
// User cancelled
return false;
}
if (authSessionResult.type === "error") {
throw new Error(
authSessionResult.error?.description ?? "Unknown error",
);
}

if (authSessionRequest.state !== authSessionResult.params.state) {
throw new Error("State mismatch");
}
if (authSessionResult.type !== "success") {
// User cancelled
return false;
}

if (!authSessionRequest.codeVerifier) {
throw new Error("Code verifier missing");
}
if (authSessionRequest.state !== authSessionResult.params.state) {
throw new Error("State mismatch");
}

if (!authSessionRequest.codeVerifier) {
throw new Error("Code verifier missing");
}

const tokenRequest = new AccessTokenRequest({
code: authSessionResult.params.code,
clientId,
redirectUri,
extraParams: {
code_verifier: authSessionRequest.codeVerifier,
},
});

const tokenResponse = await tokenRequest.performAsync({ tokenEndpoint });
const meta = await storeTokenResponse(tokenResponse);
set(meta);

return true;
},

signOut: async () => {
const tokenResponse = await readTokenResponse();
if (tokenResponse?.accessToken) {
const revokeTokenRequest = new RevokeTokenRequest({
const tokenRequest = new AccessTokenRequest({
code: authSessionResult.params.code,
clientId,
token: tokenResponse.accessToken,
redirectUri,
extraParams: {
code_verifier: authSessionRequest.codeVerifier,
},
});
await revokeTokenRequest.performAsync({ revocationEndpoint });
}
await storage.clear();
set(emptySession);
},

getAccessToken: async (options) => {
const forceRefresh = options?.forceRefresh ?? false;
try {
let tokenResponse = await readTokenResponse();

if (!tokenResponse) {
const tokenResponse = await tokenRequest.performAsync({
tokenEndpoint,
});
const meta = await storeTokenResponse(tokenResponse);
set(meta);

return true;
},

signOut: async () => {
try {
const tokenResponse = await readTokenResponse();
if (tokenResponse?.accessToken) {
const revokeTokenRequest = new RevokeTokenRequest({
clientId,
token: tokenResponse.accessToken,
});
await revokeTokenRequest.performAsync({ revocationEndpoint });
}
} catch (error) {
// Revocation is best-effort (e.g. offline) — always clear locally
if (devMode) console.error(error);
} finally {
await storage.clear();
set(emptySession);
return null;
}

if (forceRefresh || tokenResponse.shouldRefresh()) {
const refreshTokenRequest = new RefreshTokenRequest({
refreshToken: tokenResponse.refreshToken,
clientId,
});

tokenResponse = await refreshTokenRequest.performAsync({
tokenEndpoint,
});

const meta = await storeTokenResponse(tokenResponse);
set(meta);
},

getAccessToken: async (options) => {
const forceRefresh = options?.forceRefresh ?? false;
try {
const tokenResponse = await readTokenResponse();

if (!tokenResponse) {
await storage.clear();
set(emptySession);
return null;
}

if (forceRefresh || tokenResponse.shouldRefresh()) {
try {
return await refreshSession(tokenResponse);
} catch (error) {
// Only an OAuth rejection (e.g. revoked refresh token) means the
// session is dead. Transient failures — no network, server
// hiccups — must not sign the user out; keep the session so a
// later call can retry the refresh.
if (error instanceof TokenError) throw error;
if (devMode) console.error(error);
return TokenResponse.isTokenFresh(tokenResponse)
? tokenResponse.accessToken
: null;
}
}

return tokenResponse.accessToken;
} catch (error) {
if (devMode) console.error(error);
await storage.clear();
set(emptySession);
return null;
}

return tokenResponse.accessToken;
} catch (error) {
if (devMode) console.error(error);
await storage.clear();
set(emptySession);
return null;
}
},
}));
},
};
});

// Auto-restore session on creation
(async () => {
Expand Down
14 changes: 14 additions & 0 deletions tests/mocks/expo-auth-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,19 @@ export const __defaults = {
refreshTokenPerformAsync: vi.fn(),
revokeTokenPerformAsync: vi.fn(),
shouldRefresh: vi.fn().mockReturnValue(false),
isTokenFresh: vi.fn().mockReturnValue(false),
codeVerifier: "test-code-verifier" as string | undefined,
};

export class TokenError extends Error {
code: string;

constructor(params: { error: string; error_description?: string }) {
super(params.error_description ?? params.error);
this.code = params.error;
}
}

export class AuthRequest {
state = "test-state";
codeVerifier: string | undefined = __defaults.codeVerifier;
Expand Down Expand Up @@ -89,4 +99,8 @@ export class TokenResponse {
shouldRefresh() {
return __defaults.shouldRefresh();
}

static isTokenFresh(token: unknown) {
return __defaults.isTokenFresh(token);
}
}
Loading