@framework-cwf/auth
v0.2.2
Published
TypeScript port of the Cognito PKCE auth flow from sniply-barber-fe/js/auth.js, with a Server-Component-friendly <AuthProvider> context.
Downloads
115
Readme
@framework-cwf/auth
TypeScript port of the Cognito Hosted UI + PKCE authorization-code flow used
by the existing customer-site demo at sniply-barber-fe/js/auth.js. The
package preserves the 11-function public surface so downstream code can
import named functions instead of reaching for a window.Auth global.
The package ships two complementary surfaces:
- Core auth module — the 11 plain-TS functions ported from the demo
(
initiateLogin,handleCallback,resolveSession, …). Plus aconfigure()entry point so the module is SSR-safe at import time and anauthEventsEventTargetthat emits typed lifecycle events. - React surface —
<AuthProvider>(Server Component),useAuth()hook,<AuthCallbackHandler />for the/auth/callbackroute, plus the<AuthHydrator>client boundary the provider wraps. See "React surface" below andexample/for copy-paste templates.
Both surfaces are import-safe in Node — no window access at module
load — so the package drops cleanly into a Next.js static export.
Installation
Published to GitHub Packages under the @framework-cwf scope. Consumers need an
.npmrc pointing the scope at the GitHub Packages registry plus an auth token:
@framework-cwf:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}pnpm add @framework-cwf/authPublic API
| Function | Behaviour |
| ----------------------------- | ------------------------------------------------------------------------------------------------------ |
| configure(config) | Provide Cognito + proxy coordinates. Must run before any other function. |
| initiateLogin() | Generate a PKCE verifier/challenge, store the return URL, redirect to Cognito's /oauth2/authorize. |
| handleCallback() | At /auth/callback: exchange ?code= for tokens, persist them, emit auth:login. |
| resolveSession() | Load session, refresh if expired or close to expiry (visible tab only), fetch user + customer profile. |
| refreshSession(existing) | Exchange the stored refresh token for a new access token; emit auth:refresh or auth:expired. |
| loadSession() | Synchronous read of the current unexpired session, or null. |
| saveSession(session) | Persist a session to sessionStorage. |
| clearSession() | Wipe session + refresh token + PKCE verifier from storage. |
| hasSession() / isAuthed() | Boolean: true if a valid session OR a stored refresh token exists. |
| logout() | Emit auth:logout, clear session, redirect to Cognito /logout. |
| buildLogoutUrl() | Return the Cognito /logout?client_id=…&logout_uri=… URL string. |
Storage
| Token / value | Storage | Key |
| ------------------------------ | ---------------- | -------------------- |
| accessToken, idToken, etc. | sessionStorage | customer_session |
| refresh_token | localStorage | auth_refresh_token |
| PKCE code verifier | localStorage | pkce_verifier |
| Return URL after login | sessionStorage | auth_return_url |
The choice of session-vs-local mirrors the demo and is load-bearing for the resume-on-reload UX (refresh tokens survive a tab close; access tokens do not).
Upgrades over the legacy demo
- Clock-skew tolerance. The proactive-refresh threshold is bumped from 5 minutes to 5 minutes + 60s, so a device clock running fast won't catch us with an unexpectedly-expired token.
visibilitychangegating. Proactive refresh only fires whendocument.visibilityState === 'visible'. Backgrounded tabs no longer churn refresh tokens.- Typed event emissions. A module-level
authEventsEventTarget emits typedCustomEvents forauth:login,auth:logout,auth:refresh,auth:expired, andauth:error. The T1.C.2<AuthProvider>subscribes here.
Usage
import {
authEvents,
configure,
initiateLogin,
resolveSession,
} from "@framework-cwf/auth";
configure({
cognitoClientId: process.env.COGNITO_CLIENT_ID!,
cognitoHostedUiDomain: "https://dev-auth-booking.rosenheimbookings.com",
redirectUri: window.location.origin + "/auth/callback/",
proxyBaseUrl: process.env.PROXY_BASE_URL,
businessGuid: process.env.BUSINESS_GUID,
});
authEvents.addEventListener("auth:login", (ev) => {
console.log("logged in", (ev as CustomEvent).detail);
});
const session = await resolveSession();
if (!session) await initiateLogin();React surface
The package exports a Server-Component-friendly provider and a hook for reading the resolved session from React.
| Export | Where it runs | Purpose |
| ------------------------- | ---------------- | -------------------------------------------------------------------------------------------------- |
| <AuthProvider> | Server Component | Outer wrapper. Renders only its children + the hydrator — no browser APIs at SSR time. |
| <AuthHydrator> | 'use client' | The actual stateful subtree the provider mounts. Re-exported for advanced compositions. |
| useAuth() | 'use client' | Returns { status, session, refresh, logout }. Throws if called outside the provider. |
| <AuthCallbackHandler /> | 'use client' | Drop-in component for /auth/callback. Calls handleCallback() then redirects to the return URL. |
| AuthContext | 'use client' | Raw React context if you need to compose with another provider. |
status cycles through 'loading' → 'authenticated' | 'unauthenticated'
on first mount and updates from the authEvents subscription thereafter:
"use client";
import { useAuth, initiateLogin } from "@framework-cwf/auth";
export function AccountWidget() {
const { status, session, logout } = useAuth();
if (status === "loading") return <span aria-busy>Checking sign-in…</span>;
if (status === "unauthenticated")
return <button onClick={() => void initiateLogin()}>Members login</button>;
return (
<span>
Hi, {(session?.user as { name?: string } | null)?.name ?? "member"}
<button onClick={() => void logout()}>Sign out</button>
</span>
);
}Wrap your App Router root layout:
// app/layout.tsx — Server Component
import { AuthProvider } from "@framework-cwf/auth";
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}And drop the callback handler at /auth/callback/page.tsx:
// app/auth/callback/page.tsx
"use client";
import { AuthCallbackHandler } from "@framework-cwf/auth";
export default function CallbackPage() {
return <AuthCallbackHandler />;
}Error handling
<AuthProvider> is a Server Component, so it cannot accept function
props (RSC props must be serialisable). The hydrator logs auth:error
events to console.warn by default. Consumers who want richer handling
subscribe to authEvents directly:
"use client";
import { authEvents } from "@framework-cwf/auth";
useEffect(() => {
const onError = (ev: Event) => {
const { phase, error } = (ev as CustomEvent).detail;
track("auth_error", { phase, message: error.message });
};
authEvents.addEventListener("auth:error", onError);
return () => authEvents.removeEventListener("auth:error", onError);
}, []);Perf-neutral by construction
A page that never calls useAuth() ships zero extra client JS for auth —
Next.js tree-shakes unused context consumers, and the hydrator's effects
fire after first paint. See example/README.md for the empirical
Lighthouse comparison plan (lands with apps/template in T1.G.2).
SSR safety
The module is import-safe in Node — no window, document, localStorage,
sessionStorage, or fetch access at top level. loadSession(),
hasSession(), clearSession(), and saveSession() no-op in a non-browser
context; initiateLogin() and handleCallback() throw if called without a
browser environment.
Tests
pnpm --filter @framework-cwf/auth testThe unit suite covers every public function across happy and error paths
using stubbed window / storage / fetch globals. An optional integration
suite (src/integration.test.ts) exercises the dev Cognito user pool
(eu-west-2_ofaIjrHM4); it is automatically skipped unless the
COGNITO_* env vars listed at the top of the file are present.
