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
106 changes: 106 additions & 0 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,109 @@ fetch(apiUrl("/marketplace")); // "/marketplace" locally, absolute when configur

- [`README.md`](../README.md) — setup and scripts
- [`.env.example`](../.env.example) — copy-paste template

## Session model (wallet sign-in)

Callora uses wallet-based authentication via Freighter and the backend
`POST /api/auth/wallet` endpoint. This section explains the token lifecycle,
storage decisions, and the automatic refresh flow.

### Sign-in sequence

```
Browser Freighter Callora API
| | |
| requestAccess() | |
|-------------------------->| |
|<----- address ------------| |
| | |
| signMessage(challenge) | |
| challenge includes: | |
| • app origin | |
| • wallet address | |
| • ISO timestamp | |
| • expiry (+5 min) | |
| • random nonce | |
|-------------------------->| |
|<----- signedMessage ------| |
| | |
| POST /api/auth/wallet | |
| { walletAddress, | |
| signature, | |
| message } | |
|----------------------------------------------> |
|<-- { accessToken, | |
| refreshToken }------|---------------------|
| | |
| store tokens (see below) | |
```

The signed challenge binds the signature to:
- **App origin** — a signature captured on `attacker.com` cannot be replayed
on `callora.org`.
- **Expiry** — the backend can reject challenges older than 5 minutes.
- **Nonce** — two sign-ins at the same second produce different messages.

### Token storage

| Token | Storage | Rationale |
|---|---|---|
| Access token | Module-level variable (in memory only) | Never written to any browser storage. Survives page navigation within the SPA; cleared on page reload. |
| Refresh token | `sessionStorage` | Survives navigation and back/forward cache; cleared when the tab is closed. Guarded with `try/catch` so blocked storage (private browsing, full quota) degrades gracefully. |

**Why not `localStorage`?** Tokens in `localStorage` persist until explicitly
deleted and are accessible to any script running on the same origin (e.g.
through an XSS). `sessionStorage` is scoped to the tab and cleared when it
closes, which is a meaningful reduction in exposure.

**Why not `httpOnly` cookies?** An `httpOnly` cookie is the most secure option
because it is inaccessible to JavaScript entirely. The backend does not yet set
one (`authController.ts` TODO). Once the backend adds `Set-Cookie:
callora-rt=...; HttpOnly; Secure; SameSite=Strict`, the frontend should:

1. Remove the `sessionStorage` write in `authService.ts`.
2. Remove the `refreshToken` field from the JSON body sent to `/api/auth/refresh`.
3. Drop the `clearRefreshToken()` call in `signOut` (the cookie clears via `/api/auth/revoke`).

### Refresh and retry sequence

```
authFetch("/api/usage", init)
│
├── fetch(url, { Authorization: Bearer <token> })
│ │
│ [200] ──────────────────────────────────────> return response
│ │
│ [401] ──> ensureRefresh() ← shared across concurrent calls
│ │
│ POST /api/auth/refresh
│ │
│ [200] new accessToken ─> retry fetch
│ │ │
│ [401/5xx] ──> signOut() │
│ signalExpiry() │
│ reject │
│ │
│ [200] ──> return response
│ [401] ──> signOut() + signalExpiry() + return 401
```

Key properties:
- **Single-flight refresh.** Three concurrent 401s share one `POST /api/auth/refresh`.
The second and third calls await the promise started by the first.
- **Abort propagation.** An `AbortSignal` passed to `authFetch` is forwarded to
both the initial request and the retry; it is checked before starting the
retry as well, so a cancelled caller never triggers an unnecessary network request.
- **Session-expired event.** When the refresh itself fails, `signalExpiry()` is
called, which `useSessionExpiry` subscribes to. `AppSessionExpiryBanner` in
`App.tsx` renders `SessionExpiryBanner` when the session is expired.

### Relevant source files

| File | Purpose |
|---|---|
| `src/services/authService.ts` | `signIn`, `signOut`, `getAccessToken`, `refreshAccessToken` |
| `src/services/authFetch.ts` | `authFetch` with single-flight refresh |
| `src/services/sessionExpiry.ts` | Signal bus for session expiry events |
| `src/hooks/useSessionExpiry.ts` | React hook wrapping the signal bus |
| `src/components/SessionExpiryBanner.tsx` | UI banner shown on expiry |
126 changes: 126 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts";
import OnboardingTour from "./pages/OnboardingTour";
import { ShortcutsModal } from "./components/ShortcutsModal";
import { useAccountContext } from "./hooks/useAccountContext";
import { signIn, signOut, isSignedIn, type AuthServiceErrorCode } from "./services/authService";
import { useSessionExpiry } from "./hooks/useSessionExpiry";
import SessionExpiryBanner from "./components/SessionExpiryBanner";
import WalletStatus from "./components/WalletStatus";
import { useWalletStore } from "./state/walletStore";

Expand Down Expand Up @@ -962,12 +965,14 @@ function App() {
))}
</nav>
<AccountSwitcher />
<WalletSignInButton />
<WalletStatus />
<ThemeToggle />
</div>
</header>

<main id="main-content" role="main" className="page">
<AppSessionExpiryBanner />
<LiveRegion message={balanceAnnouncement} />
<Suspense fallback={<div className="route-loading-fallback" aria-busy="true" aria-label="Loading page" style={{ minHeight: "300px" }} />}>
<Routes>
Expand Down Expand Up @@ -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 (
<div style={{ padding: "0 24px" }}>
<SessionExpiryBanner onDismiss={dismiss} />
</div>
);
}

/**
* 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<string | null>(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 (
<div className="wallet-signin-btn-wrap" style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4 }}>
{signedIn ? (
<button
type="button"
className="wallet-signin-btn wallet-signin-btn--out"
onClick={handleSignOut}
disabled={loading}
aria-busy={loading}
aria-label="Sign out of Callora"
style={WALLET_BTN_STYLE}
>
{loading ? "Signing out…" : "Sign out"}
</button>
) : (
<button
type="button"
className="wallet-signin-btn wallet-signin-btn--in"
onClick={handleSignIn}
disabled={loading}
aria-busy={loading}
aria-label="Sign in with Freighter wallet"
style={{ ...WALLET_BTN_STYLE, background: "var(--accent, #4e85ff)", color: "#fff", border: "none" }}
>
{loading ? "Signing in…" : "Sign in"}
</button>
)}
{error && (
<span
role="alert"
aria-live="polite"
style={{ fontSize: "0.75rem", color: "var(--danger, #ff7d8d)", maxWidth: 220, textAlign: "right" }}
>
{error}
</span>
)}
</div>
);
}

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);
Expand Down
4 changes: 2 additions & 2 deletions src/pages/ApiUsage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
}),
Expand Down
Loading