@clocklobster/cognito-client
v1.1.3
Published
Generic, dependency-injected browser AWS Cognito client — sign-up, sign-in, session restore/refresh, NEW_PASSWORD_REQUIRED challenge, forgot/reset password
Downloads
1,054
Maintainers
Readme
cognito-client
A generic, dependency-injected browser client for AWS Cognito
user pools — sign-up, confirmation, sign-in, session restore/refresh, sign-out, forgot/reset
password, and the NEW_PASSWORD_REQUIRED challenge. No product coupling, no hardcoded
routes, no default targets — every dependency is injected.
Why this exists: AWS Amplify Auth is heavyweight and opinionated. The raw
amazon-cognito-identity-jsSDK is callback-based and untyped. This client wraps the SDK into a clean, typed, Promise-based surface with every product concern (storage, error messages, navigation, pool config) injected — so you own the UX and policy, and the client owns the Cognito mechanics.
Table of contents
- Overview
- Features
- Install
- Quick start
- Configuration
- API reference
CognitoClientsignUp(email, password, attributeList?)confirmSignUp(email, code)signIn(email, password)completeNewPassword(newPassword, userAttributes?)getSession()ensureSession(loginUrl)refreshSession()forgotPassword(email)confirmNewPassword(email, code, newPassword)signOut()redirectToLogin(loginUrl)- Token accessors
- Types
- Dependency injection
- The NEW_PASSWORD_REQUIRED challenge
- Session persistence and the post-login redirect
- Security model
- Consumer data-handling
- Testing
- Development
- Project layout
- Comparison with Amplify
- Contributing
- License
Overview
cognito-client provides a product-neutral Cognito lifecycle for browser applications.
It wraps the amazon-cognito-identity-js SDK (loaded as a browser global or injected as
a mock) behind a typed, Promise-based interface:
- Sign-up + email confirmation
- Sign-in with
NEW_PASSWORD_REQUIREDchallenge support - Session restore (survives page reload / post-login redirect)
- Session refresh (uses cached refresh token)
- Forgot password + confirm new password
- Sign-out (clears tokens + SDK session)
- Redirect to login with
?returnTo=preservation
Every product concern is injected:
sdk— theamazon-cognito-identity-jsnamespaceuserPoolId/clientId— pool configuration (plain strings or lazy suppliers)storage— theStorageused for SDK persistence (bindsessionStorageso tokens never touchlocalStorage)errorMapper— maps SDK errors to your product's error copynavigate/getCurrentPath— navigation hooks forredirectToLogin
Features
- Fully dependency-injected — no hardcoded pool IDs, no hardcoded routes, no hardcoded error messages. You own the product policy; the client owns the Cognito mechanics.
- Promise-based — wraps the callback-based SDK into clean async/await
- TypeScript-native — full types for every method, option, and SDK interface
- NEW_PASSWORD_REQUIRED challenge — surfaced as a
SignInResult.challenge, not an error - Session persistence — tokens survive the post-login redirect via the injected
Storage - Lazy pool config —
userPoolId/clientIdcan be functions resolved at first use (for apps that load config at runtime) - Token safety — runtime tokens live in memory only; the SDK session (refresh token)
lives in the injected
Storage(usesessionStorageso it clears on tab close) - Product-neutrality tested — a test asserts the core source contains no product roles, routes, or copy
- Zero runtime dependencies — only dev dependencies (TypeScript, Vitest, fast-check, jsdom, Stryker)
Install
npm install @clocklobster/cognito-client
# or
pnpm add @clocklobster/cognito-clientPeer requirement
This client wraps amazon-cognito-identity-js. Install it in your app and pass the SDK namespace to the client constructor:
npm install amazon-cognito-identity-jsRequirements
- Browser environment (uses
Storage,windownavigation) - TypeScript >= 5 (for type consumers; ships
.d.tsfiles) amazon-cognito-identity-jsloaded as a browser global or importable module
Quick start
import { CognitoClient } from '@clocklobster/cognito-client';
// Load the SDK — as a browser global, or via import:
// import * as AmazonCognitoIdentity from 'amazon-cognito-identity-js';
const cognito = new CognitoClient({
// Pool configuration (plain strings or lazy suppliers)
userPoolId: 'us-east-1_XXXXXXXXX',
clientId: 'your-app-client-id',
// The amazon-cognito-identity-js namespace
sdk: AmazonCognitoIdentity,
// Storage for the SDK's session (refresh token).
// Use sessionStorage so tokens never touch localStorage and clear on tab close.
storage: sessionStorage,
// Map SDK errors to your app's error messages
errorMapper: (err) => {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('NotAuthorizedException')) {
return new Error('Incorrect email or password.');
}
if (msg.includes('UserNotFoundException')) {
return new Error('No account found with that email.');
}
return new Error(msg || 'Authentication failed.');
},
// Navigation (used by redirectToLogin)
navigate: (url) => (window.location.href = url),
getCurrentPath: () => window.location.pathname + window.location.search,
});
// Sign up
await cognito.signUp('[email protected]', 'SecurePassword123!', [
{ Name: 'email', Value: '[email protected]' },
]);
// Confirm sign-up
await cognito.confirmSignUp('[email protected]', '123456');
// Sign in
const result = await cognito.signIn('[email protected]', 'SecurePassword123!');
if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
// Collect a new password from the user, then:
const tokens = await cognito.completeNewPassword('NewSecurePassword456!', {
// Optional: required attribute updates from the challenge
name: 'Jane',
});
console.log('Signed in:', tokens.idToken);
} else {
console.log('Signed in:', result.idToken);
}
// On page load (e.g. in your app bootstrap), restore the session:
const session = await cognito.getSession();
if (session) {
console.log('Restored session for', session.user);
console.log('ID token:', session.idToken);
}
// Sign out
cognito.signOut();Configuration
CognitoClientOptions
| Option | Type | Required | Description |
|---|---|---|---|
| userPoolId | string \| (() => string) | yes | Cognito User Pool ID (e.g. us-east-1_XXXXX). Can be a lazy supplier resolved at first auth operation. |
| clientId | string \| (() => string) | yes | Cognito App Client ID. Can be a lazy supplier. |
| sdk | CognitoSdk | yes | The amazon-cognito-identity-js namespace (browser global or imported module). |
| storage | Storage \| (() => Storage \| undefined) | yes | The Storage used for ALL SDK persistence. Bind sessionStorage so tokens never touch localStorage. Can be a lazy supplier. |
| errorMapper | (err: unknown) => Error | yes | Maps SDK errors to your app's error messages. The SDK throws opaque errors; this is your chance to translate them. |
| navigate | (url: string) => void | yes | Navigation function used by redirectToLogin. Typically (url) => window.location.href = url. |
| getCurrentPath | () => string | no | Returns the current page path + query, used to build ?returnTo=. Defaults to empty string (no returnTo). |
Lazy suppliers
userPoolId, clientId, and storage accept either a plain value or a function. The
function is resolved at first auth operation (not at construction), so apps that load
config at runtime (e.g. from a fetched config endpoint) can supply a lazy supplier:
const cognito = new CognitoClient({
userPoolId: () => appConfig.cognito.userPoolId,
clientId: () => appConfig.cognito.clientId,
// ...
});API reference
CognitoClient
import { CognitoClient } from '@clocklobster/cognito-client';
const cognito = new CognitoClient(options);Method-level companion with signatures, returns/throws, and the exported utilities
(isTokenExpired(), assertSessionStorageOnly()): docs/api.md.
signUp(email, password, attributeList?)
Registers a new user in the Cognito user pool.
const result = await cognito.signUp('[email protected]', 'Password123!', [
{ Name: 'email', Value: '[email protected]' },
{ Name: 'phone_number', Value: '+14165551234' },
]);
// result: { userConfirmed: boolean, userSub: string }attributeList— array of{ Name, Value }Cognito attributes (defaults to[])- Returns
{ userConfirmed, userSub }—userConfirmedisfalseif email/SMS verification is required before sign-in
confirmSignUp(email, code)
Confirms a sign-up using the verification code sent to the user's email/SMS.
await cognito.confirmSignUp('[email protected]', '123456');signIn(email, password)
Authenticates a user. Returns either tokens (success) or a NEW_PASSWORD_REQUIRED
challenge that must be completed via completeNewPassword().
const result = await cognito.signIn('[email protected]', 'Password123!');
if (result.challenge === null) {
// Success — tokens are available
console.log(result.idToken, result.accessToken);
} else if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
// User must set a new permanent password
console.log(result.userAttributes);
console.log(result.requiredAttributes);
}Returns: SignInResult — either:
{ challenge: null, idToken: string, accessToken: string }, or{ challenge: 'NEW_PASSWORD_REQUIRED', userAttributes, requiredAttributes }
On success, the session is persisted to the injected Storage so it survives the
post-login redirect.
completeNewPassword(newPassword, userAttributes?)
Completes a NEW_PASSWORD_REQUIRED challenge issued by signIn(). Throws if no
challenge is in flight.
const tokens = await cognito.completeNewPassword('NewPassword456!', {
name: 'Jane Doe', // optional required attribute updates
});
// tokens: { idToken, accessToken }userAttributes— any required attribute updates from the challenge. Thesubattribute is automatically scrubbed (Cognito rejects resending it).- On success, tokens are stored and the challenge state is cleared.
- On failure, the challenge state is cleared and the user must sign in again.
getSession()
Restores a cached session from the injected Storage. Used on page load to check
if the user is already authenticated.
const session = await cognito.getSession();
if (session) {
console.log('User:', session.user);
console.log('ID token:', session.idToken);
console.log('Access token:', session.accessToken);
} else {
// Not authenticated — redirect to login
cognito.redirectToLogin('/login.html');
}Returns: RestoredSession | null
- On success:
{ idToken, accessToken, user } - On failure (stale/invalid session): signs out the SDK user, clears tokens, returns
null - On synchronous SDK throw (no cached refresh token): clears tokens, returns
null
ensureSession(loginUrl)
Canonical async page-load gate for protected pages. Unlike a sync token-presence check (which passes with a stale-but-cached token after credentials timeout, letting the page fetch and render private data before the 401 path discovers the dead session), this validates the session through the SDK first.
const session = await cognito.ensureSession('/login.html');
if (!session) return; // dead session — already redirected to login
// live session (refreshed via the stored refresh token when needed)
console.log('ID token:', session.idToken);Returns: RestoredSession | null — never throws.
- Live session: returned as
{ idToken, accessToken, user }, refreshing via the stored refresh token when the id token expired but the refresh token is still alive (seamless, no redirect). - Dead session: signs out the stale SDK state, redirects to
loginUrl(with?returnTo=preservation), returnsnull— before any API fetch fires.
refreshSession()
Refreshes the session using the cached refresh token. Safe to call even when the SDK's
signInUserSession has not been loaded into memory yet (e.g. an API call races the
page's own getSession() call).
const tokens = await cognito.refreshSession();
// tokens: { idToken, accessToken }Returns: SessionTokens — { idToken, accessToken }
Throws: if there is no cached session or the refresh token is invalid.
forgotPassword(email)
Initiates the forgot-password flow. Cognito sends a verification code to the user's
email/SMS. Resolves when the code has been sent (the inputVerificationCode callback
fires).
await cognito.forgotPassword('[email protected]');
// Now prompt the user for the code + new passwordconfirmNewPassword(email, code, newPassword)
Completes the forgot-password flow by submitting the verification code and a new password.
await cognito.confirmNewPassword('[email protected]', '123456', 'NewPassword789!');signOut()
Signs out the current user from the SDK and clears all token/challenge state.
cognito.signOut();Clears:
idToken,accessToken,currentUserpendingChallengeUser(any in-flightNEW_PASSWORD_REQUIREDchallenge)
redirectToLogin(loginUrl)
Signs out any stale Cognito session, then navigates to loginUrl with the current page
preserved as ?returnTo= so the login page can send the user back after sign-in.
cognito.redirectToLogin('/login.html');
// Navigates to: /login.html?returnTo=%2Fdashboard%3Ftab%3DsettingsloginUrl— the login page URL (product policy — the adapter owns this)- Signs out first so the login form doesn't pick up a cached user whose tokens are dead
- Uses
getCurrentPath()(if provided) to build the?returnTo=query parameter
Token accessors
| Method | Returns | Description |
|---|---|---|
| getUser() | string \| null | The current user's username (or null if not signed in) |
| getIdToken() | string \| null | The current Cognito ID token JWT (or null) |
| getAccessToken() | string \| null | The current Cognito access token JWT (or null) |
These return null when no session is active. They read from in-memory state set by
signIn(), completeNewPassword(), getSession(), or refreshSession().
Types
// SignIn result — either tokens (success) or a challenge
type SignInResult =
| { challenge: null; idToken: string; accessToken: string }
| {
challenge: 'NEW_PASSWORD_REQUIRED';
userAttributes: Record<string, unknown>;
requiredAttributes: Record<string, unknown>;
};
// Session tokens
interface SessionTokens {
idToken: string;
accessToken: string;
}
// Restored session (from getSession)
interface RestoredSession extends SessionTokens {
user: string;
}
// The SDK surface the client uses (inject amazon-cognito-identity-js)
interface CognitoSdk {
CognitoUserPool: new (data: { UserPoolId: string; ClientId: string; Storage?: Storage }) => CognitoUserPoolLike;
CognitoUser: new (data: { Username: string; Pool: CognitoUserPoolLike; Storage?: Storage }) => CognitoUserLike;
AuthenticationDetails: new (data: { Username: string; Password: string }) => unknown;
}
// Client options
interface CognitoClientOptions {
userPoolId: string | (() => string);
clientId: string | (() => string);
sdk: CognitoSdk;
storage: Storage | (() => Storage | undefined);
errorMapper: (err: unknown) => Error;
navigate: (url: string) => void;
getCurrentPath?: () => string;
}Dependency injection
Every dependency is injected — the client has zero hardcoded values:
| Dependency | Purpose | Typical binding |
|---|---|---|
| sdk | The amazon-cognito-identity-js namespace | Browser global or import * as |
| userPoolId | Cognito User Pool ID | String from config/env |
| clientId | Cognito App Client ID | String from config/env |
| storage | SDK session persistence | sessionStorage (never localStorage) |
| errorMapper | SDK error → user-facing error | Your app's error message map |
| navigate | Page navigation for redirectToLogin | (url) => window.location.href = url |
| getCurrentPath | Current path for ?returnTo= | () => window.location.pathname + window.location.search |
This means:
- No hardcoded pool IDs — different environments (dev/staging/prod) inject different pools
- No hardcoded error messages — your app owns the UX copy
- No hardcoded routes — your app owns the login URL and redirect logic
- No hardcoded storage — bind
sessionStoragefor tab-scoped sessions, or a customStorageimplementation for testing
The NEW_PASSWORD_REQUIRED challenge
When a user signs in and Cognito requires a new permanent password (e.g. admin-created/
invited users in FORCE_CHANGE_PASSWORD state), signIn() does not throw an error.
Instead, it returns a challenge result:
const result = await cognito.signIn(email, tempPassword);
if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
// The user is authenticated but must set a new password.
// Show a "set new password" form, then:
const tokens = await cognito.completeNewPassword(newPassword, {
name: 'Jane', // optional required attributes
});
// tokens.idToken / tokens.accessToken are now available
}Key details:
- The
CognitoUserreference is held internally during the challenge — you don't need to re-authenticate to complete it - The
subattribute is automatically scrubbed fromuserAttributes(Cognito rejects resending it — it's read-only/server-managed) - On success, tokens are stored and the challenge state is cleared
- On failure, the challenge state is cleared and the user must sign in again
Session persistence and the post-login redirect
Cognito's SDK persists the session (refresh token) to the injected Storage. This
client binds sessionStorage by convention so:
- Runtime tokens live in memory only —
idToken/accessTokenare never written to storage; they're held in theCognitoClientinstance - The SDK session (refresh token) lives in
sessionStorage— so it survives the post-login redirect but clears when the tab closes - On page load, call
getSession()to restore the session fromsessionStorage
// App bootstrap (every page load):
const session = await cognito.getSession();
if (session) {
// User is authenticated — render the app
initApp(session);
} else {
// Not authenticated — redirect to login
cognito.redirectToLogin('/login.html');
}
// Login page (after successful signIn):
const result = await cognito.signIn(email, password);
if (result.challenge === null) {
// Validate ?returnTo= before navigating: same-origin path only (see
// docs/plan-evidence.md validation rules — rejects absolute URLs,
// protocol-relative URLs, backslash escapes, control chars, and
// scheme/absolute URLs with ':' before any '/?#'; falls back to '/').
function resolveReturnTo(raw: string | null): string {
if (!raw) return '/';
let v = raw;
try {
v = decodeURIComponent(raw);
} catch {
return '/';
}
if (!v.startsWith('/') || v.startsWith('//') || v.includes('\\')) return '/';
if (/[\x00-\x1f\x7f]/.test(v)) return '/';
const q = v.search(/[\/?#]/);
const pre = q === -1 ? v : v.slice(0, q);
if (pre.includes(':')) return '/';
return raw;
}
window.location.href = resolveReturnTo(
new URLSearchParams(window.location.search).get('returnTo'),
);
}Security model
- Runtime tokens in memory only —
idTokenandaccessTokenare never written toStorage. They live in theCognitoClientinstance and are cleared on sign-out or terminal failure. - SDK session in
sessionStorage— the refresh token persists in the injectedStorage(bindsessionStorage, notlocalStorage, so it clears on tab close). - Terminal failure clears state — a stale/invalid cached session triggers
signOut()clearTokens(), so no dead token state survives.
- Sign-out is thorough — calls
cognitoUser.signOut()on the SDK AND clears all in-memory token/challenge state. redirectToLoginsigns out first — so the login form doesn't pick up a cached user whose tokens are dead.subis scrubbed —completeNewPasswordstrips thesubattribute fromuserAttributesbefore sending (Cognito rejects resending it).
Consumer data-handling
- What is stored where — runtime
idToken/accessToken(andcurrentUser) live in memory only on theCognitoClientinstance; they are never written toStorage,localStorage, cookies, URLs, or logs. Only the SDK session (refresh token) lives in the injectedStorageso it survives the post-login redirect. - Tab-close clearing — bind the injected
storagetosessionStorage, neverlocalStorage, so the SDK session clears when the tab closes. Do not copygetIdToken()/getAccessToken()into storage, URLs, or telemetry yourself; re-read via the accessors orrefreshSession()when expired. errorMapperPII / user-enumeration caution — do not echo raw SDK messages to the UI. DistinguishingUserNotFoundException("no account") fromNotAuthorizedException("wrong password") leaks account enumeration; prefer a single generic message (e.g. "Incorrect email or password.") unless your product explicitly accepts the enumeration trade-off.- HTTPS / CSP host-page expectations — serve the host page over HTTPS,
set a
Content-Security-Policythat disallows inline-script open redirects, and validate?returnTo=withresolveReturnTo(same-origin path only) before assigning towindow.location.href.
Testing
The suite uses Vitest with a jsdom environment and a mock SDK.
59 tests across 9 describe blocks:
| Describe block | Tests | Coverage |
|---|---|---|
| CognitoClient - dependency injection | 5 | Lazy pool config, lazy storage, SDK injection, error mapper |
| CognitoClient - signIn / session | 10 | signIn success, NEW_PASSWORD_REQUIRED challenge, completeNewPassword, getSession, refreshSession, forgotPassword, confirmNewPassword |
| CognitoClient - sign-out / navigation | 3 | signOut, redirectToLogin with ?returnTo=, stale session cleanup |
| CognitoClient - ensureSession page-load gate | 4 | Live session, dead-session redirect, refresh path, never-throws contract |
| CognitoClient - product neutrality | 2 | No product roles/routes on the prototype, no product terms in source |
| CognitoClient - property tests | 23 | Invariants over generated inputs (tokens, scrubbing, lazy config, redirect, sign-up, errors) |
| isTokenExpired - fail-closed JWT expiry probe | 2 | Expiry/skew boundaries, malformed tokens and missing exp |
| CognitoClient - SDK error and edge-case behavior | 7 | Edge paths where mutants previously survived |
| cognito-client storage guard | 3 | sessionStorage accepted, localStorage rejected, initPool fails closed |
npm test # vitest run (jsdom, mock SDK — no real Cognito calls)The product-neutrality test asserts that the CognitoClient source contains no
product-specific terms (roles, routes, copy) — this guarantees the core stays generic
as it evolves.
Development
# Install dependencies
pnpm install
# Typecheck
pnpm run typecheck # tsc --noEmit
# Run tests (jsdom + mock SDK — no real Cognito)
pnpm test # vitest run
# Build (emit to dist/)
pnpm run build # tsc -p tsconfig.build.jsonpostinstall runs tsc -p tsconfig.build.json on every install, so dist/
is rebuilt automatically after pnpm install.
CI re-run notes
- Dependency audit (blocking):
corepack pnpm audit --audit-level=critical - Dependency audit (advisory, non-blocking):
corepack pnpm audit --audit-level=high(continue-on-error: truein CI — re-run locally to triage new advisories) - Secret scan (fail-closed): run the
Secret scanstep of theverifyjob in.github/workflows/ci.yml(canonical pattern list)
Requirements
- Node.js 22 (see
.nvmrc) - pnpm 11 via corepack (or npm/yarn — the package has no runtime dependencies)
- TypeScript >= 5
Project layout
cognito-client/
├── src/
│ └── index.ts # CognitoClient + all types (single file, 579 lines)
├── test/
│ ├── cognito-client.test.ts # 33 unit tests
│ ├── cognito-client.property.test.ts # 23 property tests
│ └── storage-guard.test.ts # 3 storage-guard tests
├── examples/
│ ├── quickstart.ts # offline consumer wiring (injected SDK stub, allowlisted returnTo)
│ └── session-lifecycle.ts # offline full session lifecycle (sign-in → refresh → sign-out)
├── docs/
│ ├── api.md # method-level API companion
│ └── plan-evidence.md # canonical returnTo validation rules
├── package.json
├── tsconfig.json # typechecks src + test + examples (noEmit)
├── tsconfig.build.json # src-only build (never ships examples to dist)
├── vitest.config.ts
├── stryker.config.json
├── CHANGELOG.md
├── LICENSE
└── README.mdComparison with Amplify
| | cognito-client | AWS Amplify Auth |
|---|---|---|
| Dependencies | Zero runtime (you inject the SDK) | Heavyweight (~50 deps) |
| Bundle size | ~4KB (your code only) | ~100KB+ |
| Error messages | You own them (errorMapper) | Amplify's defaults |
| Routes | You own them (navigate) | Amplify's hosted UI / config |
| Storage | You choose (sessionStorage recommended) | Amplify's localStorage default |
| Pool config | String or lazy supplier | Static config at init |
| NEW_PASSWORD_REQUIRED | First-class challenge result | Handled internally |
| Product coupling | None (tested) | Amplify ecosystem assumptions |
| TypeScript | Full types, strict | Full types |
When to use cognito-client: you want a thin, typed, dependency-injected Cognito
wrapper that you fully control. You own the UX, the error messages, the storage strategy,
and the routing.
When to use Amplify: you want a batteries-included auth solution with hosted UI, social providers, MFA, and the full Amplify ecosystem — and you're OK with the bundle size and opinionated defaults.
Contributing
Pull requests are welcome. See CONTRIBUTING.md for setup, quality gates, and the release checklist. This project follows the Code of Conduct; report security issues per SECURITY.md.
Guidelines
- Add or update tests for any change (Vitest, jsdom, mock SDK).
- Ensure
pnpm run typecheckandpnpm testpass. - Do not commit secrets,
.envfiles, ordist/output. - Follow the existing code style (strict TypeScript, no
any, dependency injection). - Keep the core product-neutral — the product-neutrality test must stay green. No
product roles, routes, or copy in
src/index.ts.
License
MIT © Victor Salmon
