diff --git a/src/features/auth/model/auth.tsx b/src/features/auth/model/auth.tsx index b1dfa64..95d3408 100644 --- a/src/features/auth/model/auth.tsx +++ b/src/features/auth/model/auth.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { createContext, useContext, useEffect, useRef, useState } from "react"; -import { apiClient } from "../../../shared/api"; +import { apiClient, configureAuthClient } from "../../../shared/api"; import type { paths } from "../../../shared/api/generated/schema"; type User = @@ -97,6 +97,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } } + useEffect(() => { + configureAuthClient({ + accessToken: () => accessTokenRef.current, + refresh, + clear: () => { + setUser(null); + setAccessToken(null); + accessTokenRef.current = null; + queryClient.clear(); + }, + }); + return () => configureAuthClient(null); + }); + return ( { + afterEach(() => { + configureAuthClient(null); + vi.unstubAllGlobals(); + }); + + it("refreshes once and retries an unauthorized protected request", async () => { + let token = "expired"; + const refresh = vi.fn(async () => { + token = "fresh"; + return true; + }); + const fetch = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ code: "UNAUTHORIZED" }), { status: 401 }), + ) + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })); + vi.stubGlobal("fetch", fetch); + configureAuthClient({ accessToken: () => token, refresh, clear: vi.fn() }); + + const { data } = await apiClient.GET("/api/v1/brokerage-accounts"); + + expect(data).toEqual([]); + expect(refresh).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0][0].headers.get("Authorization")).toBe( + "Bearer expired", + ); + expect(fetch.mock.calls[1][0].headers.get("Authorization")).toBe( + "Bearer fresh", + ); + }); + + it("clears the session when refresh cannot restore it", async () => { + const clear = vi.fn(); + const fetch = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ code: "UNAUTHORIZED" }), { status: 401 }), + ); + vi.stubGlobal("fetch", fetch); + configureAuthClient({ + accessToken: () => "expired", + refresh: async () => false, + clear, + }); + + await apiClient.GET("/api/v1/brokerage-accounts"); + + expect(clear).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts index 20c0e7c..c413024 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -3,7 +3,56 @@ import createClient from "openapi-fetch"; import { apiBaseUrl } from "./config"; import type { paths } from "./generated/schema"; +type AuthHandlers = { + accessToken: () => string | null; + refresh: () => Promise; + clear: () => void; +}; + +let auth: AuthHandlers | null = null; + +export function configureAuthClient(handlers: AuthHandlers | null) { + auth = handlers; +} + +async function authenticatedFetch(request: Request): Promise { + const isAuthRequest = new URL(request.url).pathname.startsWith( + "/api/v1/auth/", + ); + const token = auth?.accessToken(); + const withToken = (source: Request, value: string) => { + const headers = new Headers(source.headers); + headers.set("Authorization", `Bearer ${value}`); + return new Request(source, { headers }); + }; + const authorized = + token && !isAuthRequest ? withToken(request, token) : request; + const retryRequest = authorized.clone(); + const response = await fetch(authorized); + if ( + response.status !== 401 || + isAuthRequest || + !auth || + ( + await response + .clone() + .json() + .catch(() => null) + )?.code !== "UNAUTHORIZED" + ) + return response; + if (!(await auth.refresh())) { + auth.clear(); + return response; + } + const refreshedToken = auth.accessToken(); + return refreshedToken + ? fetch(withToken(retryRequest, refreshedToken)) + : response; +} + export const apiClient = createClient({ baseUrl: apiBaseUrl, credentials: "include", + fetch: authenticatedFetch, }); diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 798e6e4..9a4bb86 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -136,6 +136,21 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + ProblemDetailsDto: { + /** @example 401 */ + status: number; + /** @example UNAUTHORIZED */ + code: string; + /** + * @example [ + * { + * "pointer": "#/email", + * "detail": "must be a valid email address" + * } + * ] + */ + errors?: string[]; + }; CredentialsDto: { /** @example user@example.com */ email: string; @@ -218,6 +233,42 @@ export interface operations { "application/json": components["schemas"]["AuthenticationDto"]; }; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; }; }; AuthController_login: { @@ -241,6 +292,42 @@ export interface operations { "application/json": components["schemas"]["AuthenticationDto"]; }; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; }; }; AuthController_refresh: { @@ -260,6 +347,42 @@ export interface operations { "application/json": components["schemas"]["AuthenticationDto"]; }; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; }; }; AuthController_logout: { @@ -277,6 +400,42 @@ export interface operations { }; content?: never; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; }; }; AuthController_me: { @@ -296,6 +455,42 @@ export interface operations { "application/json": components["schemas"]["UserDto"]; }; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; }; }; TbankConnectionController_status: { @@ -315,6 +510,42 @@ export interface operations { "application/json": components["schemas"]["TbankConnectionStatusDto"]; }; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; }; }; TbankConnectionController_put: { @@ -338,6 +569,42 @@ export interface operations { "application/json": components["schemas"]["TbankConnectionStatusDto"]; }; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; }; }; TbankConnectionController_delete: { @@ -355,6 +622,42 @@ export interface operations { }; content?: never; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; }; }; BrokerageAccountsController_list: { @@ -374,6 +677,42 @@ export interface operations { "application/json": components["schemas"]["BrokerageAccountDto"][]; }; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; }; }; PortfolioController_get: { @@ -395,6 +734,42 @@ export interface operations { "application/json": components["schemas"]["PortfolioDto"]; }; }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; }; }; } diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 3db77ad..f67c226 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -1,2 +1,2 @@ -export { apiClient } from "./client"; +export { apiClient, configureAuthClient } from "./client"; export { apiBaseUrl } from "./config";