aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-01-01 21:19:30 +0900
committernsfisis <nsfisis@gmail.com>2026-01-01 21:19:30 +0900
commitbf286c11d3244afb5132271dac656109934150e0 (patch)
tree7188425dbe9652c11bd85bb653bd32ee5d49bd3c
parent803b3dd4a87ba2711605b565ee1eaf5992daf267 (diff)
downloadkioku-bf286c11d3244afb5132271dac656109934150e0.tar.gz
kioku-bf286c11d3244afb5132271dac656109934150e0.tar.zst
kioku-bf286c11d3244afb5132271dac656109934150e0.zip
fix(auth): add automatic token refresh on 401 responses
The client session was too short because access tokens (15 min) weren't being automatically refreshed using the refresh token (7 days). Now the ApiClient intercepts 401 responses, attempts token refresh, and retries the original request. This extends effective session duration to 7 days. Closes #6 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--src/client/api/client.ts58
-rw-r--r--src/client/stores/sync.test.tsx3
-rw-r--r--src/client/stores/sync.tsx19
3 files changed, 60 insertions, 20 deletions
diff --git a/src/client/api/client.ts b/src/client/api/client.ts
index 7607eb6..b39f064 100644
--- a/src/client/api/client.ts
+++ b/src/client/api/client.ts
@@ -61,12 +61,50 @@ export function createClient(baseUrl: string): Client {
export class ApiClient {
private tokenStorage: TokenStorage;
private refreshPromise: Promise<boolean> | null = null;
+ private baseUrl: string;
public readonly rpc: Client;
constructor(options: ApiClientOptions = {}) {
- const baseUrl = options.baseUrl ?? window.location.origin;
+ this.baseUrl = options.baseUrl ?? window.location.origin;
this.tokenStorage = options.tokenStorage ?? localStorageTokenStorage;
- this.rpc = createClient(baseUrl);
+ this.rpc = this.createAuthenticatedClient();
+ }
+
+ private createAuthenticatedClient(): Client {
+ return hc<AppType>(this.baseUrl, {
+ fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
+ return this.authenticatedFetch(input, init);
+ },
+ });
+ }
+
+ async authenticatedFetch(
+ input: RequestInfo | URL,
+ init?: RequestInit,
+ ): Promise<Response> {
+ const tokens = this.tokenStorage.getTokens();
+ const headers = new Headers(init?.headers);
+
+ if (tokens?.accessToken && !headers.has("Authorization")) {
+ headers.set("Authorization", `Bearer ${tokens.accessToken}`);
+ }
+
+ const response = await fetch(input, { ...init, headers });
+
+ if (response.status === 401 && tokens?.refreshToken) {
+ // Try to refresh the token
+ const refreshed = await this.refreshToken();
+ if (refreshed) {
+ // Retry with new token
+ const newTokens = this.tokenStorage.getTokens();
+ if (newTokens?.accessToken) {
+ headers.set("Authorization", `Bearer ${newTokens.accessToken}`);
+ }
+ return fetch(input, { ...init, headers });
+ }
+ }
+
+ return response;
}
private async handleResponse<T>(response: Response): Promise<T> {
@@ -108,15 +146,25 @@ export class ApiClient {
}
try {
- const res = await this.rpc.api.auth.refresh.$post({
- json: { refreshToken: tokens.refreshToken },
+ // Use raw fetch to avoid infinite loop through authenticatedFetch
+ const res = await fetch(`${this.baseUrl}/api/auth/refresh`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ refreshToken: tokens.refreshToken }),
});
if (!res.ok) {
+ // Clear tokens if refresh fails
+ this.tokenStorage.clearTokens();
return false;
}
- const data = await res.json();
+ const data = (await res.json()) as {
+ accessToken: string;
+ refreshToken: string;
+ };
this.tokenStorage.setTokens({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
diff --git a/src/client/stores/sync.test.tsx b/src/client/stores/sync.test.tsx
index fee79d7..20de69d 100644
--- a/src/client/stores/sync.test.tsx
+++ b/src/client/stores/sync.test.tsx
@@ -16,6 +16,9 @@ global.fetch = mockFetch;
vi.mock("../api/client", () => ({
apiClient: {
getAuthHeader: vi.fn(() => ({ Authorization: "Bearer token" })),
+ authenticatedFetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
+ mockFetch(input, init),
+ ),
},
}));
diff --git a/src/client/stores/sync.tsx b/src/client/stores/sync.tsx
index aea5c16..9b46302 100644
--- a/src/client/stores/sync.tsx
+++ b/src/client/stores/sync.tsx
@@ -108,15 +108,9 @@ interface PullResponse {
}
async function pushToServer(data: SyncPushData): Promise<SyncPushResult> {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new Error("Not authenticated");
- }
-
- const res = await fetch("/api/sync/push", {
+ const res = await apiClient.authenticatedFetch("/api/sync/push", {
method: "POST",
headers: {
- ...authHeader,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
@@ -135,14 +129,9 @@ async function pushToServer(data: SyncPushData): Promise<SyncPushResult> {
async function pullFromServer(
lastSyncVersion: number,
): Promise<SyncPullResult> {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new Error("Not authenticated");
- }
-
- const res = await fetch(`/api/sync/pull?lastSyncVersion=${lastSyncVersion}`, {
- headers: authHeader,
- });
+ const res = await apiClient.authenticatedFetch(
+ `/api/sync/pull?lastSyncVersion=${lastSyncVersion}`,
+ );
if (!res.ok) {
const errorBody = (await res.json().catch(() => ({}))) as {