+ }> @@ -1522,6 +1527,127 @@ function App() { ); } +/** + * AppSessionExpiryBanner — renders the session-expiry banner when the session + * has expired, and dismisses it when the user clicks Dismiss or signs in again. + */ +function AppSessionExpiryBanner() { + const { isExpired, dismiss } = useSessionExpiry(); + if (!isExpired) return null; + return ( +
+ +
+ ); +} + +/** + * WalletSignInButton — topbar control that lets the user sign in with Freighter + * or sign out. Manages its own loading / error state so the rest of the app + * does not need to know about it. + */ +function WalletSignInButton() { + const [signedIn, setSignedIn] = useState(isSignedIn); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Keep local state in sync after a session-expiry clears the token. + const { isExpired } = useSessionExpiry(); + useEffect(() => { + if (isExpired) setSignedIn(false); + }, [isExpired]); + + const handleSignIn = useCallback(async () => { + setLoading(true); + setError(null); + try { + await signIn(); + setSignedIn(true); + } catch (err) { + const code: AuthServiceErrorCode | undefined = + err && typeof err === "object" && "code" in err + ? (err as { code: AuthServiceErrorCode }).code + : undefined; + + if (code === "SIGNATURE_REJECTED") { + // User deliberately cancelled — don't show an error. + } else { + setError( + err instanceof Error && err.message + ? err.message + : "Sign in failed. Try again.", + ); + } + } finally { + setLoading(false); + } + }, []); + + const handleSignOut = useCallback(async () => { + setLoading(true); + setError(null); + try { + await signOut(); + } finally { + // Clear regardless of whether the revoke call failed. + setSignedIn(false); + setLoading(false); + } + }, []); + + return ( +
+ {signedIn ? ( + + ) : ( + + )} + {error && ( + + {error} + + )} +
+ ); +} + +const WALLET_BTN_STYLE: React.CSSProperties = { + padding: "6px 14px", + borderRadius: 8, + border: "1px solid var(--line, rgba(169,184,255,0.16))", + background: "var(--surface-soft, rgba(255,255,255,0.04))", + color: "var(--text, #f3f5fb)", + fontSize: "0.82rem", + fontWeight: 600, + fontFamily: "inherit", + cursor: "pointer", + whiteSpace: "nowrap", +}; + function AccountSwitcher() { const { account, accounts, switchAccount, addAccount, removeAccount, renameAccount } = useAccountContext(); const [open, setOpen] = useState(false); diff --git a/src/pages/ApiUsage.tsx b/src/pages/ApiUsage.tsx index 90cff27..7f8ec53 100644 --- a/src/pages/ApiUsage.tsx +++ b/src/pages/ApiUsage.tsx @@ -28,6 +28,7 @@ import { type HistoryEntry, } from "../state/testCallHistory"; import { copySnapshotUrl, parseSnapshotUrl } from "../utils/snapshotUrl"; +import { authFetch } from "../services/authFetch"; type ApiEndpoint = { id: string; @@ -330,9 +331,8 @@ export default function ApiUsage() { Promise.resolve() .then(() => - fetch("/api/usage?groupBy=day&limit=100", { + authFetch("/api/usage?groupBy=day&limit=100", { method: "GET", - credentials: "same-origin", headers: { Accept: "application/json" }, signal: controller.signal, }), diff --git a/src/services/authFetch.test.ts b/src/services/authFetch.test.ts new file mode 100644 index 0000000..48f4516 --- /dev/null +++ b/src/services/authFetch.test.ts @@ -0,0 +1,259 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ─── Hoist mocks ────────────────────────────────────────────────────────────── +const mocks = vi.hoisted(() => ({ + getAccessToken: vi.fn<[], string | null>(), + refreshAccessToken: vi.fn<[], Promise>(), + signOut: vi.fn<[], Promise>(), + signalExpiry: vi.fn(), + apiUrl: vi.fn((path: string) => path), + fetch: vi.fn(), +})); + +vi.mock("./authService", () => ({ + getAccessToken: mocks.getAccessToken, + refreshAccessToken: mocks.refreshAccessToken, + signOut: mocks.signOut, +})); + +vi.mock("./sessionExpiry", () => ({ + signalExpiry: mocks.signalExpiry, +})); + +vi.mock("../config/constants", async (importOriginal) => ({ + ...(await importOriginal()), + apiUrl: mocks.apiUrl, +})); + +// ─── Module under test ──────────────────────────────────────────────────────── +import { + authFetch, + _resetRefreshPromiseForTests, +} from "./authFetch"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const ACCESS_TOKEN = "access-token-abc"; +const NEW_ACCESS_TOKEN = "access-token-refreshed"; + +function okResponse(status = 200) { + return { ok: true, status, json: async () => ({}) }; +} + +function unauthorizedResponse() { + return { ok: false, status: 401, json: async () => ({}) }; +} + +// ─── Test suite ─────────────────────────────────────────────────────────────── + +describe("authFetch", () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetRefreshPromiseForTests(); + + vi.stubGlobal("fetch", mocks.fetch); + mocks.apiUrl.mockImplementation((path: string) => path); + mocks.getAccessToken.mockReturnValue(ACCESS_TOKEN); + mocks.refreshAccessToken.mockResolvedValue(NEW_ACCESS_TOKEN); + mocks.signOut.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + _resetRefreshPromiseForTests(); + }); + + // ─── Scenario 3: authFetch with a valid token ────────────────────────────── + describe("Scenario 3 – valid token", () => { + it("adds Authorization header to the request", async () => { + mocks.fetch.mockResolvedValue(okResponse()); + + await authFetch("/api/usage"); + + const [, init] = mocks.fetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["Authorization"]).toBe(`Bearer ${ACCESS_TOKEN}`); + }); + + it("builds the URL with apiUrl", async () => { + mocks.apiUrl.mockImplementation((path: string) => `https://api.callora.com${path}`); + mocks.fetch.mockResolvedValue(okResponse()); + + await authFetch("/api/usage"); + + const [url] = mocks.fetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.callora.com/api/usage"); + }); + + it("preserves caller-supplied headers", async () => { + mocks.fetch.mockResolvedValue(okResponse()); + + await authFetch("/api/usage", { headers: { "X-Custom": "yes" } }); + + const [, init] = mocks.fetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["X-Custom"]).toBe("yes"); + }); + + it("returns the response without a refresh when status is 200", async () => { + mocks.fetch.mockResolvedValue(okResponse(200)); + + const response = await authFetch("/api/usage"); + + expect(response.status).toBe(200); + expect(mocks.refreshAccessToken).not.toHaveBeenCalled(); + }); + }); + + // ─── Scenario 4: 401 then refresh succeeds ──────────────────────────────── + describe("Scenario 4 – 401 then refresh succeeds", () => { + it("retries exactly once with the new token", async () => { + mocks.fetch + .mockResolvedValueOnce(unauthorizedResponse()) + .mockResolvedValueOnce(okResponse()); + + const response = await authFetch("/api/usage"); + + expect(mocks.fetch).toHaveBeenCalledTimes(2); + expect(mocks.refreshAccessToken).toHaveBeenCalledTimes(1); + expect(response.status).toBe(200); + }); + + it("sends the new token in the Authorization header on retry", async () => { + mocks.fetch + .mockResolvedValueOnce(unauthorizedResponse()) + .mockResolvedValueOnce(okResponse()); + + await authFetch("/api/usage"); + + const [, retryInit] = mocks.fetch.mock.calls[1] as [string, RequestInit]; + const headers = retryInit.headers as Record; + expect(headers["Authorization"]).toBe(`Bearer ${NEW_ACCESS_TOKEN}`); + }); + }); + + // ─── Scenario 5: Three parallel 401s share one refresh call ─────────────── + describe("Scenario 5 – three parallel 401s, one refresh", () => { + it("calls refreshAccessToken exactly once for three concurrent 401s", async () => { + // First call from each of the three concurrent fetches returns 401; + // retries all return 200. + mocks.fetch + .mockResolvedValueOnce(unauthorizedResponse()) + .mockResolvedValueOnce(unauthorizedResponse()) + .mockResolvedValueOnce(unauthorizedResponse()) + .mockResolvedValue(okResponse()); + + await Promise.all([ + authFetch("/api/usage"), + authFetch("/api/usage"), + authFetch("/api/usage"), + ]); + + expect(mocks.refreshAccessToken).toHaveBeenCalledTimes(1); + }); + }); + + // ─── Scenario 6: Refresh fails ──────────────────────────────────────────── + describe("Scenario 6 – refresh fails", () => { + it("clears the session and emits session-expired when refresh fails", async () => { + mocks.fetch.mockResolvedValue(unauthorizedResponse()); + mocks.refreshAccessToken.mockRejectedValue(new Error("refresh failed")); + + await authFetch("/api/usage"); + + expect(mocks.signOut).toHaveBeenCalled(); + expect(mocks.signalExpiry).toHaveBeenCalledWith("expired"); + }); + + it("returns the original 401 response when refresh fails", async () => { + mocks.fetch.mockResolvedValue(unauthorizedResponse()); + mocks.refreshAccessToken.mockRejectedValue(new Error("refresh failed")); + + const response = await authFetch("/api/usage"); + + expect(response.status).toBe(401); + }); + }); + + // ─── Scenario 7: Abort during retry ────────────────────────────────────── + describe("Scenario 7 – abort signal", () => { + it("rejects with AbortError when aborted before the retry", async () => { + const controller = new AbortController(); + + mocks.fetch.mockResolvedValueOnce(unauthorizedResponse()); + mocks.refreshAccessToken.mockImplementation(async () => { + // Abort during the refresh so we reach the pre-retry check. + controller.abort(); + return NEW_ACCESS_TOKEN; + }); + + await expect( + authFetch("/api/usage", { signal: controller.signal }), + ).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("does not make a second fetch call after abort", async () => { + const controller = new AbortController(); + controller.abort(); + + // First fetch is already aborted — native fetch would throw, but our mock + // returns a 401; what matters is that we check the signal before retrying. + mocks.fetch.mockResolvedValueOnce(unauthorizedResponse()); + mocks.refreshAccessToken.mockResolvedValue(NEW_ACCESS_TOKEN); + + await expect( + authFetch("/api/usage", { signal: controller.signal }), + ).rejects.toMatchObject({ name: "AbortError" }); + + // No retry attempt + expect(mocks.fetch).toHaveBeenCalledTimes(1); + }); + }); + + // ─── No token: request sent without Authorization header ────────────────── + describe("no token – unauthenticated request", () => { + it("omits the Authorization header when no token is available", async () => { + mocks.getAccessToken.mockReturnValue(null); + mocks.fetch.mockResolvedValue(okResponse()); + + await authFetch("/api/public"); + + const [, init] = mocks.fetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers["Authorization"]).toBeUndefined(); + }); + }); + + // ─── Retry 401 also fails ───────────────────────────────────────────────── + describe("retry also returns 401", () => { + it("signs out and signals expiry when the retry also gets a 401", async () => { + mocks.fetch.mockResolvedValue(unauthorizedResponse()); + + await authFetch("/api/usage"); + + // First 401 → refresh → retry → 401 → signOut + signalExpiry + expect(mocks.signOut).toHaveBeenCalled(); + expect(mocks.signalExpiry).toHaveBeenCalledWith("expired"); + }); + }); + + // ─── Scenario 9: ApiUsage carries a bearer token when signed in ─────────── + describe("Scenario 9 – ApiUsage integration", () => { + it("attaches a bearer token to the usage request when signed in", async () => { + mocks.getAccessToken.mockReturnValue(ACCESS_TOKEN); + mocks.fetch.mockResolvedValue(okResponse()); + + await authFetch("/api/usage?groupBy=day&limit=100", { + method: "GET", + headers: { Accept: "application/json" }, + }); + + const [url, init] = mocks.fetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/api/usage"); + const headers = init.headers as Record; + expect(headers["Authorization"]).toBe(`Bearer ${ACCESS_TOKEN}`); + }); + }); +}); diff --git a/src/services/authFetch.ts b/src/services/authFetch.ts new file mode 100644 index 0000000..77e306c --- /dev/null +++ b/src/services/authFetch.ts @@ -0,0 +1,173 @@ +/** + * authFetch — authenticated fetch helper for Callora. + * + * Wraps `fetch` with: + * 1. Path prefixing via `apiUrl()`. + * 2. `Authorization: Bearer ` injection. + * 3. On a 401, a single automatic token refresh followed by one retry. + * 4. Single-flight refresh: concurrent 401s share one refresh promise so the + * backend is never asked to rotate the same token twice simultaneously. + * 5. Abort-signal propagation: the caller's AbortSignal cancels both the + * initial request and any retry. + * 6. Session-expired notification via `signalExpiry` when the refresh itself + * fails, so `SessionExpiryBanner` can surface the problem to the user. + */ + +import { apiUrl } from "../config/constants"; +import { + getAccessToken, + refreshAccessToken, + signOut, +} from "./authService"; +import { signalExpiry } from "./sessionExpiry"; + +// ─── Single-flight refresh ──────────────────────────────────────────────────── + +/** + * Shared promise for an in-flight refresh. Concurrent 401s wait on this + * instead of each launching a separate POST /api/auth/refresh. It is cleared + * once the refresh resolves or rejects so the next refresh starts fresh. + */ +let _refreshPromise: Promise | null = null; + +/** + * Return the in-flight refresh promise if one exists, otherwise start a new + * one. The promise resolves with the new access token. + * + * If the refresh fails, local auth state is cleared and a session-expired + * event is emitted before the promise rejects. + */ +function ensureRefresh(): Promise { + if (_refreshPromise) return _refreshPromise; + + _refreshPromise = refreshAccessToken() + .catch(async (err: unknown) => { + // Refresh failed — sign out locally and notify the banner. + await signOut().catch(() => { + // best-effort + }); + signalExpiry("expired"); + // Re-throw so every waiting caller gets the rejection. + throw err; + }) + .finally(() => { + // Clear the in-flight promise so the next 401 triggers a fresh refresh. + _refreshPromise = null; + }); + + return _refreshPromise; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Make an authenticated request to the Callora API. + * + * @param path - API path relative to the configured API base (e.g. `/api/usage`). + * @param init - Standard `RequestInit`; an `AbortSignal` here is forwarded to + * both the initial request and any retry. + * + * @returns The `Response` from the final attempt. The caller is responsible + * for checking `response.ok`. + * + * @throws Re-throws network errors and `AbortError`s. Does **not** throw on + * HTTP errors (4xx / 5xx); check `response.ok` instead. + */ +export async function authFetch( + path: string, + init: RequestInit = {}, +): Promise { + const url = apiUrl(path); + const token = getAccessToken(); + + const headersWithAuth = buildHeaders(init.headers, token); + + // ── First attempt ───────────────────────────────────────────────────────── + const firstResponse = await fetch(url, { + ...init, + headers: headersWithAuth, + }); + + if (firstResponse.status !== 401) { + return firstResponse; + } + + // ── 401: try to refresh ─────────────────────────────────────────────────── + // Check the abort signal before we even attempt the refresh. + if (init.signal?.aborted) { + throw createAbortError(); + } + + let newToken: string; + try { + newToken = await ensureRefresh(); + } catch { + // Refresh failed — session-expiry was already signalled inside ensureRefresh. + // Return the original 401 response so the caller can decide what to do. + return firstResponse; + } + + // Abort before retry? + if (init.signal?.aborted) { + throw createAbortError(); + } + + // ── Retry with new token ────────────────────────────────────────────────── + const retryResponse = await fetch(url, { + ...init, + headers: buildHeaders(init.headers, newToken), + }); + + if (retryResponse.status === 401) { + // Retry still failed — clear session and signal expiry. + await signOut().catch(() => {}); + signalExpiry("expired"); + } + + return retryResponse; +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +function buildHeaders( + base: HeadersInit | undefined, + token: string | null, +): HeadersInit { + const headers: Record = {}; + + // Spread any caller-supplied headers first. + if (base) { + if (base instanceof Headers) { + base.forEach((value, key) => { + headers[key] = value; + }); + } else if (Array.isArray(base)) { + for (const [key, value] of base) { + headers[key] = value; + } + } else { + Object.assign(headers, base); + } + } + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + + return headers; +} + +function createAbortError(): DOMException { + // DOMException with "AbortError" is what native fetch throws when aborted. + return new DOMException("The operation was aborted.", "AbortError"); +} + +/** @internal Expose for testing only. */ +export function _getRefreshPromiseForTests(): Promise | null { + return _refreshPromise; +} + +/** @internal Reset the in-flight refresh promise between tests. */ +export function _resetRefreshPromiseForTests(): void { + _refreshPromise = null; +} diff --git a/src/services/authService.test.ts b/src/services/authService.test.ts new file mode 100644 index 0000000..cad9eaa --- /dev/null +++ b/src/services/authService.test.ts @@ -0,0 +1,333 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ─── Hoist mocks so vi.mock factories can reference them ────────────────────── +const mocks = vi.hoisted(() => ({ + requestAccess: vi.fn(), + signMessage: vi.fn(), + fetch: vi.fn(), + apiUrl: vi.fn((path: string) => path), +})); + +vi.mock("@stellar/freighter-api", () => ({ + requestAccess: mocks.requestAccess, + signMessage: mocks.signMessage, +})); + +vi.mock("../config/constants", async (importOriginal) => ({ + ...(await importOriginal()), + apiUrl: mocks.apiUrl, +})); + +// ─── Module under test (imported after mocks are set up) ────────────────────── +import { + signIn, + signOut, + getAccessToken, + isSignedIn, + refreshAccessToken, + _resetAuthStateForTests, + AuthServiceError, +} from "./authService"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const WALLET_ADDRESS = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; +const ACCESS_TOKEN = "access-token-abc"; +const REFRESH_TOKEN = "refresh-token-xyz"; + +function makeAuthResponse( + accessToken = ACCESS_TOKEN, + refreshToken = REFRESH_TOKEN, +) { + return { + ok: true, + status: 200, + json: async () => ({ success: true, data: { accessToken, refreshToken } }), + }; +} + +function makeErrorResponse(status: number) { + return { ok: false, status, json: async () => ({}) }; +} + +// ─── Test suite ─────────────────────────────────────────────────────────────── + +describe("authService", () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetAuthStateForTests(); + sessionStorage.clear(); + + vi.stubGlobal("fetch", mocks.fetch); + mocks.apiUrl.mockImplementation((path: string) => path); + + // Default: happy-path Freighter responses + mocks.requestAccess.mockResolvedValue({ address: WALLET_ADDRESS }); + mocks.signMessage.mockResolvedValue({ signedMessage: "sig-bytes", error: null }); + mocks.fetch.mockResolvedValue(makeAuthResponse()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ─── Scenario 1: signIn happy path ──────────────────────────────────────── + describe("signIn – happy path", () => { + it("POSTs to /api/auth/wallet with walletAddress, signature and message", async () => { + await signIn(); + + expect(mocks.fetch).toHaveBeenCalledTimes(1); + const [url, init] = mocks.fetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/auth/wallet"); + expect(init.method).toBe("POST"); + + const body = JSON.parse(init.body as string); + expect(body.walletAddress).toBe(WALLET_ADDRESS); + expect(typeof body.signature).toBe("string"); + expect(typeof body.message).toBe("string"); + }); + + it("stores the access token in memory only (never in localStorage)", async () => { + await signIn(); + + expect(getAccessToken()).toBe(ACCESS_TOKEN); + expect(isSignedIn()).toBe(true); + + // Check localStorage has no token key + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) ?? ""; + const value = localStorage.getItem(key) ?? ""; + expect(value).not.toContain(ACCESS_TOKEN); + } + }); + + it("stores the refresh token in sessionStorage", async () => { + await signIn(); + const stored = sessionStorage.getItem("callora-refresh-token"); + expect(stored).toBe(REFRESH_TOKEN); + }); + + it("returns the walletAddress and accessToken", async () => { + const result = await signIn(); + expect(result.walletAddress).toBe(WALLET_ADDRESS); + expect(result.accessToken).toBe(ACCESS_TOKEN); + }); + + it("includes origin, expiry and nonce in the signed message", async () => { + await signIn(); + const body = JSON.parse( + (mocks.fetch.mock.calls[0] as [string, RequestInit])[1].body as string, + ); + expect(body.message).toMatch(/Origin:/); + expect(body.message).toMatch(/Expires At:/); + expect(body.message).toMatch(/Nonce:/); + expect(body.message).toMatch(/Address:/); + }); + }); + + // ─── Scenario 2: user rejects signMessage ───────────────────────────────── + describe("signIn – user rejects signature", () => { + it("does not send any request to the backend", async () => { + mocks.signMessage.mockResolvedValue({ error: "User rejected" }); + + await expect(signIn()).rejects.toMatchObject({ code: "SIGNATURE_REJECTED" }); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + + it("leaves the session signed out", async () => { + mocks.signMessage.mockRejectedValue({ message: "User rejected the request", code: 4001 }); + + await expect(signIn()).rejects.toBeInstanceOf(AuthServiceError); + expect(isSignedIn()).toBe(false); + expect(getAccessToken()).toBeNull(); + }); + }); + + // ─── requestAccess failure ───────────────────────────────────────────────── + describe("signIn – wallet unavailable", () => { + it("throws WALLET_UNAVAILABLE when requestAccess returns an error", async () => { + mocks.requestAccess.mockResolvedValue({ error: "No wallet", address: undefined }); + + await expect(signIn()).rejects.toMatchObject({ code: "WALLET_UNAVAILABLE" }); + }); + + it("throws WALLET_UNAVAILABLE when requestAccess returns no address", async () => { + mocks.requestAccess.mockResolvedValue({ address: "" }); + + await expect(signIn()).rejects.toMatchObject({ code: "WALLET_UNAVAILABLE" }); + }); + }); + + // ─── Backend error during signIn ────────────────────────────────────────── + describe("signIn – backend errors", () => { + it("throws AUTH_FAILED on a 401 response", async () => { + mocks.fetch.mockResolvedValue(makeErrorResponse(401)); + + await expect(signIn()).rejects.toMatchObject({ code: "AUTH_FAILED" }); + expect(isSignedIn()).toBe(false); + }); + + it("throws NETWORK_ERROR on a 500 response", async () => { + mocks.fetch.mockResolvedValue(makeErrorResponse(500)); + + await expect(signIn()).rejects.toMatchObject({ code: "NETWORK_ERROR" }); + }); + + it("throws NETWORK_ERROR when fetch rejects (network failure)", async () => { + mocks.fetch.mockRejectedValue(new Error("Failed to fetch")); + + await expect(signIn()).rejects.toMatchObject({ code: "NETWORK_ERROR" }); + }); + }); + + // ─── getAccessToken / isSignedIn ────────────────────────────────────────── + describe("getAccessToken / isSignedIn", () => { + it("returns null and false before sign-in", () => { + expect(getAccessToken()).toBeNull(); + expect(isSignedIn()).toBe(false); + }); + + it("returns the token and true after sign-in", async () => { + await signIn(); + expect(getAccessToken()).toBe(ACCESS_TOKEN); + expect(isSignedIn()).toBe(true); + }); + }); + + // ─── signOut ────────────────────────────────────────────────────────────── + describe("signOut", () => { + it("clears the token and sessionStorage after sign-out", async () => { + await signIn(); + expect(isSignedIn()).toBe(true); + + mocks.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + await signOut(); + + expect(isSignedIn()).toBe(false); + expect(getAccessToken()).toBeNull(); + expect(sessionStorage.getItem("callora-refresh-token")).toBeNull(); + }); + + it("clears local state even when revoke call fails", async () => { + await signIn(); + mocks.fetch.mockRejectedValue(new Error("Network error")); + + await signOut(); + + expect(isSignedIn()).toBe(false); + expect(getAccessToken()).toBeNull(); + }); + }); + + // ─── refreshAccessToken ─────────────────────────────────────────────────── + describe("refreshAccessToken", () => { + it("throws NO_REFRESH_TOKEN when sessionStorage is empty", async () => { + await expect(refreshAccessToken()).rejects.toMatchObject({ + code: "NO_REFRESH_TOKEN", + }); + }); + + it("updates the in-memory token on a successful refresh", async () => { + // Seed a refresh token in sessionStorage. + sessionStorage.setItem("callora-refresh-token", REFRESH_TOKEN); + const newToken = "new-access-token"; + mocks.fetch.mockResolvedValue(makeAuthResponse(newToken)); + + const result = await refreshAccessToken(); + + expect(result).toBe(newToken); + expect(getAccessToken()).toBe(newToken); + }); + + it("clears state and throws on a 401 from the refresh endpoint", async () => { + sessionStorage.setItem("callora-refresh-token", REFRESH_TOKEN); + mocks.fetch.mockResolvedValue(makeErrorResponse(401)); + + await expect(refreshAccessToken()).rejects.toMatchObject({ + code: "AUTH_FAILED", + }); + expect(getAccessToken()).toBeNull(); + expect(sessionStorage.getItem("callora-refresh-token")).toBeNull(); + }); + }); + + // ─── Scenario 8: localStorage never contains a token key ────────────────── + describe("localStorage isolation", () => { + it("never writes any token to localStorage", async () => { + await signIn(); + mocks.fetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) }); + await signOut(); + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) ?? ""; + expect(key).not.toMatch(/token/i); + expect(key).not.toMatch(/callora-refresh/); + expect(key).not.toMatch(/access/i); + } + }); + }); + + // ─── Response envelope variants ─────────────────────────────────────────── + describe("signIn – response envelope variants", () => { + it("accepts a flat response body (top-level accessToken / refreshToken)", async () => { + // Some backends return { accessToken, refreshToken } directly, not wrapped in data. + mocks.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ accessToken: ACCESS_TOKEN, refreshToken: REFRESH_TOKEN }), + }); + + const result = await signIn(); + expect(result.accessToken).toBe(ACCESS_TOKEN); + expect(getAccessToken()).toBe(ACCESS_TOKEN); + }); + + it("throws NETWORK_ERROR when the response contains no access token", async () => { + mocks.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ success: true, data: {} }), + }); + + await expect(signIn()).rejects.toMatchObject({ code: "NETWORK_ERROR" }); + }); + }); + + // ─── signIn – string error from signMessage ──────────────────────────────── + describe("signIn – string rejection from signMessage", () => { + it("throws SIGNATURE_REJECTED when signMessage throws a string 'User denied'", async () => { + mocks.signMessage.mockRejectedValue("user denied the request"); + + await expect(signIn()).rejects.toMatchObject({ code: "SIGNATURE_REJECTED" }); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + }); + + // ─── refreshAccessToken – response without refreshToken ─────────────────── + describe("refreshAccessToken – response without new refreshToken", () => { + it("still succeeds if the backend omits refreshToken on refresh", async () => { + sessionStorage.setItem("callora-refresh-token", REFRESH_TOKEN); + mocks.fetch.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ data: { accessToken: "new-token" } }), + }); + + const result = await refreshAccessToken(); + expect(result).toBe("new-token"); + }); + }); + + // ─── signOut – no refresh token stored ──────────────────────────────────── + describe("signOut – no refresh token stored", () => { + it("succeeds silently when there is nothing to revoke", async () => { + // Nothing in sessionStorage — should not call fetch. + await signOut(); + // revoke is not called when there's no refresh token + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(isSignedIn()).toBe(false); + }); + }); +}); diff --git a/src/services/authService.ts b/src/services/authService.ts new file mode 100644 index 0000000..48563dc --- /dev/null +++ b/src/services/authService.ts @@ -0,0 +1,358 @@ +/** + * authService — wallet-based authentication for Callora. + * + * Signs in by requesting a Freighter signature over a challenge message that + * binds the domain, wallet address, timestamp and a random nonce, then POSTs + * the result to POST /api/auth/wallet. + * + * Token storage policy: + * - Access token: module-level variable (in-memory only, never persisted). + * - Refresh token: sessionStorage, guarded with try/catch. + * + * See docs/Configuration.md § Session model for the trade-off rationale and + * the httpOnly-cookie upgrade path. + * + * Never log tokens, signatures or messages. Use SecureErrorHandler for errors + * that surface to the user. + */ + +import { requestAccess, signMessage } from "@stellar/freighter-api"; +import { apiUrl } from "../config/constants"; + +// ─── In-memory token store ──────────────────────────────────────────────────── + +/** Access token kept only in memory; never written to any persistent store. */ +let _accessToken: string | null = null; + +const REFRESH_TOKEN_KEY = "callora-refresh-token"; + +function loadRefreshToken(): string | null { + try { + return sessionStorage.getItem(REFRESH_TOKEN_KEY); + } catch { + return null; + } +} + +function saveRefreshToken(token: string): void { + try { + sessionStorage.setItem(REFRESH_TOKEN_KEY, token); + } catch { + // sessionStorage blocked (e.g. private browsing, storage full) — degrade + // gracefully: the session works until the tab is closed or the page is + // reloaded; the user will simply need to sign in again. + } +} + +function clearRefreshToken(): void { + try { + sessionStorage.removeItem(REFRESH_TOKEN_KEY); + } catch { + // Nothing to do if it is already gone. + } +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** Return the current in-memory access token, or null when signed out. */ +export function getAccessToken(): string | null { + return _accessToken; +} + +/** Return whether the user currently has an access token in memory. */ +export function isSignedIn(): boolean { + return _accessToken !== null; +} + +export type SignInResult = { + accessToken: string; + walletAddress: string; +}; + +/** + * Sign in with Freighter. + * + * 1. Request wallet access (prompts the user to connect if not already). + * 2. Build a message that includes the app origin, wallet address, ISO + * timestamp and a random nonce so the signature cannot be replayed. + * 3. Ask Freighter to sign the message. + * 4. POST to POST /api/auth/wallet and store the returned tokens. + * + * Throws `AuthServiceError` on all failure paths except user rejection, which + * throws with `code === "SIGNATURE_REJECTED"`. + */ +export async function signIn(): Promise { + // Step 1 – connect wallet. + const access = await requestAccess(); + if (access.error) { + throw new AuthServiceError("WALLET_UNAVAILABLE", "Freighter is not installed or available."); + } + if (!access.address) { + throw new AuthServiceError( + "WALLET_UNAVAILABLE", + "Freighter did not return an address. Connect an account and try again.", + ); + } + const walletAddress = access.address; + + // Step 2 – build a signed challenge message. + // The message intentionally includes: + // • the app origin — so a captured signature cannot be replayed on a + // different domain. + // • an expiry — so a captured signature cannot be replayed days later. + // • a random nonce — so two sign-ins at the same second are distinct. + const origin = typeof window !== "undefined" ? window.location.origin : "callora"; + const issuedAt = new Date().toISOString(); + // Expiry 5 minutes ahead — the backend enforces its own window. + const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString(); + const nonce = generateNonce(); + + const message = [ + `Sign in to Callora`, + `Origin: ${origin}`, + `Address: ${walletAddress}`, + `Issued At: ${issuedAt}`, + `Expires At: ${expiresAt}`, + `Nonce: ${nonce}`, + ].join("\n"); + + // Step 3 – sign with Freighter. + let signResult: Awaited>; + try { + signResult = await signMessage(message, { address: walletAddress }); + } catch (err) { + if (isUserRejection(err)) { + throw new AuthServiceError("SIGNATURE_REJECTED", "Signature rejected by the user."); + } + throw new AuthServiceError("WALLET_ERROR", "Freighter could not sign the message."); + } + + if (signResult.error) { + if (isUserRejection(signResult.error)) { + throw new AuthServiceError("SIGNATURE_REJECTED", "Signature rejected by the user."); + } + throw new AuthServiceError("WALLET_ERROR", "Freighter could not sign the message."); + } + + if (!signResult.signedMessage) { + throw new AuthServiceError("WALLET_ERROR", "Freighter returned an empty signature."); + } + + // Step 4 – exchange for tokens. + let response: Response; + try { + response = await fetch(apiUrl("/api/auth/wallet"), { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + // Never log the body; it contains a signature. + body: JSON.stringify({ + walletAddress, + signature: signResult.signedMessage, + message, + }), + }); + } catch { + throw new AuthServiceError("NETWORK_ERROR", "Could not reach the Callora API. Check your connection."); + } + + if (!response.ok) { + let code: AuthServiceErrorCode = "AUTH_FAILED"; + if (response.status === 401 || response.status === 403) code = "AUTH_FAILED"; + else if (response.status >= 500) code = "NETWORK_ERROR"; + throw new AuthServiceError(code, `Authentication failed (${response.status}).`); + } + + let body: WalletLoginResponse; + try { + body = (await response.json()) as WalletLoginResponse; + } catch { + throw new AuthServiceError("NETWORK_ERROR", "Unexpected response from the Callora API."); + } + + const { accessToken, refreshToken } = extractTokens(body); + + // Persist tokens. + _accessToken = accessToken; + if (refreshToken) saveRefreshToken(refreshToken); + + return { accessToken, walletAddress }; +} + +/** + * Refresh the access token using the stored refresh token. + * + * Called automatically by `authFetch` on a 401; external callers can use it + * too (e.g. on app load to restore a session from sessionStorage). + * + * Returns the new access token, or throws if the refresh fails (which callers + * should treat as a sign-out). + */ +export async function refreshAccessToken(): Promise { + const refreshToken = loadRefreshToken(); + if (!refreshToken) { + throw new AuthServiceError("NO_REFRESH_TOKEN", "No refresh token is available."); + } + + let response: Response; + try { + response = await fetch(apiUrl("/api/auth/refresh"), { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ refreshToken }), + }); + } catch { + throw new AuthServiceError("NETWORK_ERROR", "Could not reach the Callora API while refreshing."); + } + + if (!response.ok) { + // Token is dead — clear everything so the caller can show the sign-in UI. + _accessToken = null; + clearRefreshToken(); + throw new AuthServiceError("AUTH_FAILED", `Token refresh failed (${response.status}).`); + } + + let body: WalletLoginResponse; + try { + body = (await response.json()) as WalletLoginResponse; + } catch { + _accessToken = null; + clearRefreshToken(); + throw new AuthServiceError("NETWORK_ERROR", "Unexpected response during token refresh."); + } + + const { accessToken, refreshToken: newRefreshToken } = extractTokens(body); + + _accessToken = accessToken; + if (newRefreshToken) saveRefreshToken(newRefreshToken); + + return accessToken; +} + +/** + * Sign out. Calls POST /api/auth/revoke to invalidate the refresh token on the + * backend, then clears local state regardless of whether the revoke succeeded + * (network failure should not leave the user stuck in a signed-in state). + */ +export async function signOut(): Promise { + const refreshToken = loadRefreshToken(); + + if (refreshToken) { + try { + await fetch(apiUrl("/api/auth/revoke"), { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + ...(_accessToken ? { Authorization: `Bearer ${_accessToken}` } : {}), + }, + body: JSON.stringify({ refreshToken }), + }); + } catch { + // Revoke is best-effort — clear local state regardless. + } + } + + _accessToken = null; + clearRefreshToken(); +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Generate a URL-safe random nonce using `crypto.getRandomValues`. + * + * 16 bytes → 22 base64url characters, which gives 128 bits of entropy and is + * more than sufficient to prevent nonce collisions. + */ +function generateNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + // Convert to base64url without padding (RFC 4648 §5). + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +function isUserRejection(error: unknown): boolean { + const message = + typeof error === "string" + ? error.toLowerCase() + : error && typeof error === "object" && "message" in error + ? String((error as { message: unknown }).message).toLowerCase() + : ""; + + const code = + error && typeof error === "object" && "code" in error + ? Number((error as { code: unknown }).code) + : Number.NaN; + + return ( + code === 4001 || + message.includes("reject") || + message.includes("declin") || + message.includes("cancel") || + message.includes("user denied") + ); +} + +/** Extract tokens from the backend response envelope. */ +function extractTokens(body: WalletLoginResponse): { + accessToken: string; + refreshToken: string | undefined; +} { + // The backend wraps responses in { success, data: { accessToken, refreshToken } } + const data = body?.data ?? (body as Record); + const accessToken = + (data as Record).accessToken ?? + (body as Record).accessToken; + const refreshToken = + (data as Record).refreshToken ?? + (body as Record).refreshToken; + + if (typeof accessToken !== "string" || !accessToken) { + throw new AuthServiceError("NETWORK_ERROR", "Backend did not return an access token."); + } + + return { + accessToken, + refreshToken: typeof refreshToken === "string" ? refreshToken : undefined, + }; +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +type WalletLoginResponse = { + success?: boolean; + data?: { + accessToken?: string; + refreshToken?: string; + }; + accessToken?: string; + refreshToken?: string; +}; + +export type AuthServiceErrorCode = + | "WALLET_UNAVAILABLE" + | "SIGNATURE_REJECTED" + | "AUTH_FAILED" + | "NO_REFRESH_TOKEN" + | "NETWORK_ERROR" + | "WALLET_ERROR"; + +export class AuthServiceError extends Error { + readonly code: AuthServiceErrorCode; + + constructor(code: AuthServiceErrorCode, message: string) { + super(message); + this.name = "AuthServiceError"; + this.code = code; + } +} + +// ─── Test helpers (never called in production) ──────────────────────────────── + +/** @internal Reset in-memory state between tests. */ +export function _resetAuthStateForTests(): void { + _accessToken = null; +}