-
Notifications
You must be signed in to change notification settings - Fork 1
Browser usage
cognito-toolkit is server-side — nothing from the package runs in a browser. What runs in the browser is a pattern: how a web app obtains tokens, keeps them fresh, and reacts to the middleware's answers. This page documents the flow the toolkit was built against in production (paired with the double-meh family); every server-side piece it relies on ships in this package.
The guards give the browser exactly two signals, and they mean different things:
- 401 — the request carried no valid token. Either this is a first access (a bookmark, a URL from an email) or the user's authentication changed mid-session (expired, revoked, signed out elsewhere). Both have the same answer: route to login.
- 403 — the user is authenticated but not allowed. Do not route to login (it would loop); show a "no access" state.
Funnel every API call through one wrapper. On a 401, record the current URL and hand the navigation to the (customizable) Cognito Hosted UI login page:
const returnKey = 'auth-return-to';
export const api = async (url, options = {}) => {
const token = getToken(); // your storage of choice
const response = await fetch(url, {
...options,
headers: {...options.headers, ...(token ? {authorization: token} : {})} // bare token — the middleware does no Bearer stripping
});
if (response.status === 401) {
sessionStorage.setItem(returnKey, location.href); // fallback carrier for the return URL
location.replace(loginUrl()); // replace, not assign — technical redirects stay out of history
return new Promise(() => {}); // navigation is taking over; never resolve
}
return response;
};
const loginUrl = () =>
'https://auth.my-domain.com/login?' +
new URLSearchParams({
client_id: 'my-app-client-id',
response_type: 'code',
scope: 'openid profile',
redirect_uri: location.origin + '/auth/callback',
state: location.href // preferred carrier — comes back on the redirect
});The return URL rides the OAuth2 state parameter when it fits; sessionStorage (or localStorage, when the login round-trip may land in a fresh tab) is the fallback when it cannot be used any other way. The callback exchanges the code and puts the user back where they were:
// /auth/callback
const params = new URLSearchParams(location.search);
const tokens = await exchangeCode(params.get('code')); // POST /oauth2/token, grant_type=authorization_code
saveTokens(tokens);
const returnTo = params.get('state') || sessionStorage.getItem(returnKey) || '/';
sessionStorage.removeItem(returnKey);
location.replace(returnTo); // replace again — the callback URL (with its ?code=…) must not survive in historyEvery navigation the login sequence performs itself uses replace-style navigation (location.replace; history.replaceState for same-document cleanup) — never location.assign / location.href. The payoff is transparent Back-button behavior: from the restored page, Back goes to wherever the user genuinely was before, not to a dead callback URL or a login form that would bounce them forward again. The same rule applies to logout:
export const logout = () => {
clearTokens();
location.replace(
'https://auth.my-domain.com/logout?' + new URLSearchParams({client_id: 'my-app-client-id', logout_uri: location.origin + '/'})
);
};Some entries cannot be prevented — the Hosted UI page records one once the user interacts with it. Those are jumped over with history.go(-N) instead: record the history depth next to the return URL when leaving, and on return compare it with the current history.length to skip the whole technical block in one motion — landing on the original pre-login entry rather than a fresh copy of it:
// leaving for login:
sessionStorage.setItem(returnKey, JSON.stringify({url: location.href, depth: history.length}));
// in the callback:
const saved = JSON.parse(sessionStorage.getItem(returnKey) || 'null');
sessionStorage.removeItem(returnKey);
const extra = saved ? history.length - saved.depth : 0;
if (extra > 0) history.go(-(extra + 1)); // same-tab flow: hop back over the login block to the real entry
else location.replace(saved?.url || '/'); // fresh tab (or nothing to skip): replace insteadThe production rule of thumb: location.replace wherever the sequence controls the navigation, history.go(-N) where it doesn't.
Renew on a timer instead of waiting for a 401 — automatic renewal removes almost all authentication friction mid-session. Deduct a safety margin from the token lifetime (for 60-minute tokens, renew ~5 minutes early — the same gap the server-side token utilities use):
const GAP = 5 * 60 * 1000; // renew 5 minutes early, just in case
const scheduleRenewal = expiresIn => {
const expires = expiresIn * 1000;
setTimeout(renew, expires > GAP ? expires - GAP : expires / 2);
};
const renew = async () => {
if (!userIsActive()) return; // don't keep abandoned tabs authenticated forever
const response = await fetch('https://auth.my-domain.com/oauth2/token', {
method: 'POST',
headers: {'content-type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({grant_type: 'refresh_token', client_id: 'my-app-client-id', refresh_token: getRefreshToken()})
});
if (!response.ok) return; // stay quiet — the next 401 routes to login anyway
const tokens = await response.json();
saveTokens(tokens);
scheduleRenewal(tokens.expires_in);
};
scheduleRenewal(currentExpiresIn());With renewal in place, a 401 reliably means one of the two cases from the contract above — first access or a genuine status change — and both already funnel into the login redirect. No special handling needed.
The auth cookie composes with this: authenticate via the header once, let the middleware persist the token as a cookie (setAuthCookieOptions), and subsequent requests — including plain document loads that never touch your fetch wrapper — authenticate automatically.
The toolkit and the double-meh family were used together in production; the seams are:
-
The initial authentication decision lives in the code-forward script — the inline
<head>prelude fires its first server requests before the library loads, so an anonymous visitor's 401 arrives at the earliest possible moment and the login redirect happens before the app boots (no flash of an empty authenticated UI). -
double-meh-bundler endpoints sit behind the middleware — bundle-serving endpoints are guarded by
cognito-toolkit, so unauthenticated users are rejected before a bundle is accepted or served.
This is the lean, hand-rolled pattern — a fetch wrapper, a timer, and the Hosted UI. If you want the token lifecycle managed for you (storage, refresh, multi-tab sync), AWS Amplify Auth or a generic OIDC client (e.g. oidc-client-ts) covers the browser side; the server-side middlewares in this package work identically either way, since they only ever see the resulting token.
- Middleware ports — the server-side guards this flow talks to.
- Core-API — verifying tokens outside a middleware.
Start here
Reference
Under the hood