@mitkatadvisory/datasurfr-auth-utils
v1.0.1
Published
Shared auth, session management, and VAPT-compliant logout for Datasurfr apps
Readme
@mitkatadvisory/datasurfr-auth-utils
Shared auth, session management, and VAPT-compliant logout for all Datasurfr apps.
Drop this package into any app and get: token refresh, session heartbeat, cross-app logout, back-button protection, and a session-expired modal — all wired up in one config file.
Installation
npm install @mitkatadvisory/datasurfr-auth-utilsNo .npmrc needed — published to public npm under @mitkatadvisory.
Quick Start (3 steps)
Step 1 — Create src/auth.config.ts
This is the only file your app needs to write. All logic lives in the package.
Vite + React app (Axios, sessionStorage tokens)
import {
createRefreshAccessToken,
createGetAuthHeaders,
createAxiosAuthInterceptor,
clearSession,
storeTokens, getTokens, clearTokens, hasTokens,
emitSessionExpired,
} from '@mitkatadvisory/datasurfr-auth-utils';
import api from './api/axiosInstance'; // your bare Axios instance
const BASE = import.meta.env.VITE_API_INTERNAL_URL;
const LOGIN = import.meta.env.VITE_LOGIN_URL ?? '/';
const KEY = 'myapp_custom_auth'; // unique per app
type Tokens = { access_token: string; refresh_token: string };
export const storeAuthTokens = (t: Tokens) => storeTokens(KEY, t as unknown as Record<string, unknown>);
export const getAuthTokens = () => getTokens<Tokens>(KEY);
export const clearAuthTokens = () => clearTokens(KEY);
export const hasAuth = () => hasTokens(KEY);
export const refreshAccessToken = createRefreshAccessToken({
refreshEndpoint: `${BASE}/api/apps/auth/accessToken`,
getRefreshToken: () => getAuthTokens()?.refresh_token ?? null,
onNewToken: (t) => storeAuthTokens({ ...getAuthTokens()!, access_token: t }),
});
export const getAuthHeaders = createGetAuthHeaders({
getAccessToken: () => getAuthTokens()?.access_token ?? null,
getRefreshToken: () => getAuthTokens()?.refresh_token ?? null,
refreshAccessToken,
});
createAxiosAuthInterceptor(api, {
getAuthHeaders,
onAuthFailure: () => emitSessionExpired('expired'),
});
export const logout = (token?: string) => clearSession({
accessToken: token,
revokeEndpoint: `${BASE}/api/apps/auth/revoke-token`,
clearCookiesEndpoint: `${BASE}/api/apps/auth/clear-cookies`,
loginUrl: LOGIN,
});Next.js 15 App Router app (native fetch, httpOnly cookies)
import {
createRefreshAccessToken,
createGetAuthHeaders,
createFetchWithAuth,
clearSession,
emitSessionExpired,
} from '@mitkatadvisory/datasurfr-auth-utils';
import { cookies } from 'next/headers'; // only Next.js-specific line
const BASE = process.env.NEXT_PUBLIC_INTERNAL_API_BASE_URL!;
const LOGIN = process.env.NEXT_PUBLIC_REDIRECT_APPS_URL!;
export const refreshAccessToken = createRefreshAccessToken({
refreshEndpoint: `${BASE}/api/apps/auth/accessToken`,
getRefreshToken: async () => (await cookies()).get('refreshToken')?.value ?? null,
onNewToken: () => {}, // server sets cookie via Set-Cookie header
});
export const getAuthHeaders = createGetAuthHeaders({
getAccessToken: async () => (await cookies()).get('accessToken')?.value ?? null,
getRefreshToken: async () => (await cookies()).get('refreshToken')?.value ?? null,
refreshAccessToken,
});
const onAuthFailure = () => emitSessionExpired('expired');
export const fetchInternal = createFetchWithAuth({ baseUrl: BASE, getAuthHeaders, mode: 'json', onAuthFailure });
export const fetchSse = createFetchWithAuth({ baseUrl: BASE, getAuthHeaders, mode: 'stream', onAuthFailure });
export const fetchFormData = createFetchWithAuth({ baseUrl: BASE, getAuthHeaders, mode: 'formdata', onAuthFailure });
export const logout = (token?: string) => clearSession({
accessToken: token,
revokeEndpoint: `${BASE}/api/apps/auth/revoke-token`,
clearCookiesEndpoint: `${BASE}/api/apps/auth/clear-cookies`,
loginUrl: LOGIN,
});Step 2 — Wrap your app root with <SessionGuard>
<SessionGuard> provides all VAPT protections automatically with zero extra code.
Vite + React (src/main.tsx)
import { SessionGuard } from '@mitkatadvisory/datasurfr-auth-utils/react';
import { hasAuth } from './auth.config';
createRoot(document.getElementById('root')!).render(
<SessionGuard loginUrl={import.meta.env.VITE_LOGIN_URL} isAuthenticated={hasAuth}>
<App />
</SessionGuard>
);Next.js (src/app/layout.tsx)
import { SessionGuard } from '@mitkatadvisory/datasurfr-auth-utils/react';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<SessionGuard
loginUrl={process.env.NEXT_PUBLIC_REDIRECT_APPS_URL!}
isAuthenticated={() => !!sessionStorage.getItem('myapp_auth_user')}
>
{children}
</SessionGuard>
</body>
</html>
);
}Step 3 — Add session polling (optional but recommended)
// Vite + React: add in your root router/App component
import { useSessionValidator } from '@mitkatadvisory/datasurfr-auth-utils/react';
import { getAuthTokens, refreshAccessToken } from './auth.config';
useSessionValidator({
getToken: () => getAuthTokens()?.access_token ?? null,
endpoint: `${import.meta.env.VITE_API_INTERNAL_URL}/api/apps/userInfo`,
refreshAccessToken,
intervalMs: 60_000, // check every 60s
});
// Next.js: use heartbeat instead
import { useSessionHeartbeat } from '@mitkatadvisory/datasurfr-auth-utils/react';
useSessionHeartbeat({
enabled: !!token,
endpoint: '/api/user',
intervalMs: 120_000,
});Cross-App Logout
When a user logs out of any app, all open tabs across all apps are logged out automatically. No extra setup — clearSession() broadcasts and <SessionGuard> listens.
User clicks Logout in App A
│
▼
clearSession() called
├─► broadcastLogout() ──► BroadcastChannel "auth_events"
│ │
│ ┌────────────┴────────────┐
│ ▼ ▼
│ App B tab App C tab
│ SessionGuard listener SessionGuard listener
│ clears storage clears storage
│ shows session-expired shows session-expired
│ modal modal
│
├─► revokeToken() (server, fire-and-forget)
├─► clearServerCookies() (server, fire-and-forget)
├─► clearAllAuthStorage() (this tab)
└─► safeRedirectToLogin() (replace — back button goes to login, not protected page)Requirement: BroadcastChannel only works between tabs on the same browser origin. Both apps must be served from the same domain (e.g.
app.example.com). If apps are on different subdomains, use a shared cookie logout signal instead.
Publishing a New Version
First-time setup (once per machine):
npm login # use @mitkatadvisory org credentialsEvery release:
# 1. Bump version (updates package.json + creates a git tag)
npm version patch # bug fix: 1.0.0 → 1.0.1
npm version minor # new feature: 1.0.0 → 1.1.0
npm version major # breaking change: 1.0.0 → 2.0.0
# 2. Publish — prepublishOnly runs `npm run build` automatically
npm publish
# 3. Push version tag to git
git push && git push --tagsInstalling the new version in a consuming app:
npm install @mitkatadvisory/datasurfr-auth-utils@latestAPI Reference
Core (no React dependency)
| Export | Description |
|--------|-------------|
| isJwtExpired(token, bufferSecs?) | True if JWT exp within buffer (default 30s) or malformed |
| SESSION_EXPIRED_EVENT | Event name constant: "session-expired" |
| emitSessionExpired(reason) | Dispatch session-expired CustomEvent on window |
| storeTokens(key, tokens) | Save object to sessionStorage under key |
| getTokens<T>(key) | Read typed object from sessionStorage |
| clearTokens(key) | Remove item from sessionStorage |
| hasTokens(key) | True if key exists in sessionStorage |
| clearAllAuthStorage() | Wipes both localStorage and sessionStorage |
| safeRedirectToLogin(loginUrl, returnTo?) | Always uses replace() — no history entry |
| callRefreshEndpoint(endpoint, refreshToken) | Raw HTTP POST to exchange refresh token |
| createRefreshAccessToken(opts) | Factory: returns singleton-deduped refreshAccessToken() |
| buildAuthHeaders(token) | { Authorization: 'Bearer ...', 'Content-Type': 'application/json' } |
| createGetAuthHeaders(opts) | Factory: expiry-check + auto-refresh + returns { token, headers } |
| createFetchWithAuth(opts) | Factory: fetch wrapper with 401-retry (modes: json / stream / formdata) |
| createAxiosAuthInterceptor(axios, opts) | Attach 401-retry + 5xx-safe interceptor to Axios instance |
| clearSession(opts) | Broadcast + revoke + clear cookies + clear storage + redirect |
| broadcastLogout(source) | Post LOGOUT to BroadcastChannel auth_events |
| broadcastLogin(source, tokens) | Post LOGIN to BroadcastChannel |
| listenForCrossAppLogout(onLogout) | Subscribe to LOGOUT broadcasts; returns cleanup fn |
| listenForCrossAppLogin(onLogin) | Subscribe to LOGIN broadcasts; returns cleanup fn |
React (@mitkatadvisory/datasurfr-auth-utils/react)
| Export | Description |
|--------|-------------|
| createAuthProvider(opts) | Factory: returns { AuthProvider, useAuth } |
| <SessionGuard> | Root wrapper: bfcache guard + no-store meta + cross-app logout + modal |
| <SessionExpiredModal> | Non-dismissible session-expired overlay |
| useBfcacheGuard(opts) | VAPT: redirect on browser back after logout |
| useSessionHeartbeat(opts) | Poll endpoint every N ms; pause on tab hidden |
| useSessionValidator(opts) | Validate token on interval + focus + visibility change |
| useLogout(opts) | Returns { handleLogout, loading, error } |
VAPT Compliance
What this package handles automatically (zero app code needed):
| Threat | How it's handled |
|--------|-----------------|
| Back button shows auth content after logout | useBfcacheGuard inside <SessionGuard> |
| Protected page in history after logout | safeRedirectToLogin uses replace(), never href |
| Browser caches authenticated pages | <meta no-store> injected by <SessionGuard> |
| Tokens left in storage after logout | clearAllAuthStorage() inside clearSession() |
| Other tabs/apps stay logged in | broadcastLogout() in clearSession() + listener in <SessionGuard> |
| Concurrent token refresh race | Singleton dedup in createRefreshAccessToken() |
What your server must do:
POST /api/apps/auth/revoke-token— invalidate the refresh token in the databasePOST /api/apps/auth/clear-cookies— clear httpOnly session cookies from the response
