Browser SDK
The @aero2/sdk/browser subpath gives you everything you need to integrate Aero2 into a single-page app: PKCE generation, redirect login, callback handling, silent token refresh, and pluggable storage.
Works in modern browsers (Chrome, Safari, Firefox, Edge), and anywhere fetch and Web Crypto are available — that means it also runs in Cloudflare Workers and Node 18+ with no polyfills.
Install
npm install @aero2/sdk30-second quickstart
import { createBrowserClient } from "@aero2/sdk/browser";
const client = createBrowserClient({
issuer: "https://acme.aero2.dev",
clientId: "my-spa",
redirectUri: "https://app.example.com/callback",
});
// On a "Sign in" button click
button.addEventListener("click", () => client.loginWithRedirect());
// On the /callback route
const { tokens, appState } = await client.handleRedirectCallback();
// Anywhere you need the access token
const token = await client.getAccessToken();
fetch("/api/orders", {
headers: { Authorization: `Bearer ${token}` },
});
// On sign-out
await client.logout({ returnTo: "/" });How the flow works
loginWithRedirectgenerates a fresh PKCE verifier/challenge pair and a CSRF state nonce, stores the verifier in storage, then redirects to the IdP'sauthorizeendpoint with the challenge.- The IdP authenticates the user and redirects back to your
redirectUriwith?code=...&state=.... handleRedirectCallbackvalidates the state, exchanges the code + verifier for an access token + refresh token (noclient_secretneeded — that's the point of PKCE), and persists the session.getAccessTokenreturns the cached token; if it's within the refresh-skew window of expiring, it silently refreshes using the refresh token. Concurrent callers are de-duplicated to a single refresh.logoutclears local state and (optionally) navigates to a post-logout URL.
Storage adapters
The SDK uses pluggable storage so you can pick the right tradeoff for your app.
import {
createBrowserClient,
localStorageAdapter,
memoryStorage,
sessionStorageAdapter,
} from "@aero2/sdk/browser";
const client = createBrowserClient({
issuer: "https://acme.aero2.dev",
clientId: "my-spa",
redirectUri: "https://app.example.com/callback",
// Default: localStorage. Persists across page loads.
storage: localStorageAdapter(),
// sessionStorage: cleared on tab close. Stricter, no cross-tab sharing.
// storage: sessionStorageAdapter(),
// In-memory: lost on page reload. Required if your app must avoid
// any browser-persistent token storage.
// storage: memoryStorage(),
});You can also pass a custom adapter that implements TokenStorage:
import type { TokenStorage } from "@aero2/sdk/browser";
const encrypted: TokenStorage = {
getItem: async (k) => decrypt(localStorage.getItem(k)),
setItem: async (k, v) => localStorage.setItem(k, encrypt(v)),
removeItem: (k) => localStorage.removeItem(k),
};Round-tripping app state
Pass appState to loginWithRedirect to resume the user's pre-login navigation:
await client.loginWithRedirect({
appState: { returnTo: "/dashboard/team" },
});
// On the callback route:
const { appState } = await client.handleRedirectCallback();
window.history.replaceState({}, "", (appState as { returnTo?: string })?.returnTo ?? "/");appState is stored alongside the PKCE verifier under a key derived from the OAuth state nonce and consumed atomically — it's CSRF-bound to the same state value that protects the redirect.
Custom scope per call
await client.loginWithRedirect({
scope: "openid email orders:read",
});If omitted, uses the scope from createBrowserClient (default: "openid profile email").
Userinfo
const user = await client.getUser<{ sub: string; email: string; name?: string }>();Calls the /oauth2/userinfo endpoint with the current access token. Cached after first fetch — the cache is cleared on logout().
SSR / Node / Workers
The browser SDK uses globalThis.window and globalThis.localStorage by default but can be steered to other environments by passing windowImpl and a non-DOM storage:
const client = createBrowserClient({
issuer,
clientId,
redirectUri,
storage: memoryStorage(),
windowImpl: { location: { href: req.url, search: "", origin: "https://app" } },
fetchImpl: customFetch,
});This is useful for Next.js / Remix loaders that want to participate in the same session model.
Errors
import {
Aero2Error, // base class
Aero2NetworkError, // fetch failed
Aero2OAuthError, // IdP returned a non-2xx error response
} from "@aero2/sdk/browser";
try {
await client.handleRedirectCallback();
} catch (err) {
if (err instanceof Aero2OAuthError && err.errorCode === "invalid_grant") {
// Code/verifier mismatch (replay, expired, wrong app). Rare in practice.
}
throw err;
}If getAccessToken fails to refresh (e.g. the refresh token was revoked), the local session is cleared automatically — the next getAccessToken() returns null, signaling the caller to prompt for re-login.