@clocklobster/cognito-client 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,667 +1,692 @@
1
- # cognito-client
2
-
3
- [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
- [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/)
5
- [![Tests](https://img.shields.io/badge/tests-19%20passing-brightgreen.svg)](#testing)
6
-
7
- A generic, dependency-injected browser client for [AWS Cognito](https://aws.amazon.com/cognito/)
8
- user pools — sign-up, confirmation, sign-in, session restore/refresh, sign-out, forgot/reset
9
- password, and the `NEW_PASSWORD_REQUIRED` challenge. **No product coupling, no hardcoded
10
- routes, no default targets — every dependency is injected.**
11
-
12
- > **Why this exists:** AWS Amplify Auth is heavyweight and opinionated. The raw
13
- > `amazon-cognito-identity-js` SDK is callback-based and untyped. This client wraps the
14
- > SDK into a clean, typed, Promise-based surface with every product concern (storage,
15
- > error messages, navigation, pool config) injected — so you own the UX and policy, and
16
- > the client owns the Cognito mechanics.
17
-
18
- ---
19
-
20
- ## Table of contents
21
-
22
- - [Overview](#overview)
23
- - [Features](#features)
24
- - [Install](#install)
25
- - [Quick start](#quick-start)
26
- - [Configuration](#configuration)
27
- - [API reference](#api-reference)
28
- - [`CognitoClient`](#cognitoclient)
29
- - [`signUp(email, password, attributeList?)`](#signupemail-password-attributelist)
30
- - [`confirmSignUp(email, code)`](#confirmsignupemail-code)
31
- - [`signIn(email, password)`](#signinemail-password)
32
- - [`completeNewPassword(newPassword, userAttributes?)`](#completenewpasswordnewpassword-userattributes)
33
- - [`getSession()`](#getsession)
34
- - [`refreshSession()`](#refreshsession)
35
- - [`forgotPassword(email)`](#forgotpasswordemail)
36
- - [`confirmNewPassword(email, code, newPassword)`](#confirmnewpasswordemail-code-newpassword)
37
- - [`signOut()`](#signout)
38
- - [`redirectToLogin(loginUrl)`](#redirecttologinloginurl)
39
- - [Token accessors](#token-accessors)
40
- - [Types](#types)
41
- - [Dependency injection](#dependency-injection)
42
- - [The NEW_PASSWORD_REQUIRED challenge](#the-new_password_required-challenge)
43
- - [Session persistence and the post-login redirect](#session-persistence-and-the-post-login-redirect)
44
- - [Security model](#security-model)
45
- - [Testing](#testing)
46
- - [Development](#development)
47
- - [Project layout](#project-layout)
48
- - [Comparison with Amplify](#comparison-with-amplify)
49
- - [Contributing](#contributing)
50
- - [License](#license)
51
-
52
- ---
53
-
54
- ## Overview
55
-
56
- `cognito-client` provides a product-neutral Cognito lifecycle for browser applications.
57
- It wraps the `amazon-cognito-identity-js` SDK (loaded as a browser global or injected as
58
- a mock) behind a typed, Promise-based interface:
59
-
60
- - **Sign-up** + email confirmation
61
- - **Sign-in** with `NEW_PASSWORD_REQUIRED` challenge support
62
- - **Session restore** (survives page reload / post-login redirect)
63
- - **Session refresh** (uses cached refresh token)
64
- - **Forgot password** + confirm new password
65
- - **Sign-out** (clears tokens + SDK session)
66
- - **Redirect to login** with `?returnTo=` preservation
67
-
68
- Every product concern is injected:
69
- - `sdk` the `amazon-cognito-identity-js` namespace
70
- - `userPoolId` / `clientId` — pool configuration (plain strings or lazy suppliers)
71
- - `storage` the `Storage` used for SDK persistence (bind `sessionStorage` so tokens
72
- never touch `localStorage`)
73
- - `errorMapper` — maps SDK errors to your product's error copy
74
- - `navigate` / `getCurrentPath` navigation hooks for `redirectToLogin`
75
-
76
- ---
77
-
78
- ## Features
79
-
80
- - **Fully dependency-injected** — no hardcoded pool IDs, no hardcoded routes, no hardcoded
81
- error messages. You own the product policy; the client owns the Cognito mechanics.
82
- - **Promise-based** wraps the callback-based SDK into clean async/await
83
- - **TypeScript-native** — full types for every method, option, and SDK interface
84
- - **NEW_PASSWORD_REQUIRED challenge** — surfaced as a `SignInResult.challenge`, not an error
85
- - **Session persistence** — tokens survive the post-login redirect via the injected `Storage`
86
- - **Lazy pool config** — `userPoolId` / `clientId` can be functions resolved at first use
87
- (for apps that load config at runtime)
88
- - **Token safety** runtime tokens live in memory only; the SDK session (refresh token)
89
- lives in the injected `Storage` (use `sessionStorage` so it clears on tab close)
90
- - **Product-neutrality tested** a test asserts the core source contains no product
91
- roles, routes, or copy
92
- - **Zero runtime dependencies** — only dev dependencies (TypeScript, Vitest, jsdom)
93
-
94
- ---
95
-
96
- ## Install
97
-
98
- ```bash
99
- npm install @clocklobster/cognito-client
100
- # or
101
- pnpm add @clocklobster/cognito-client
102
- ```
103
-
104
- ### Peer requirement
105
-
106
- This client wraps [amazon-cognito-identity-js](https://www.npmjs.com/package/amazon-cognito-identity-js).
107
- Install it in your app and pass the SDK namespace to the client constructor:
108
-
109
- ```bash
110
- npm install amazon-cognito-identity-js
111
- ```
112
-
113
- ### Requirements
114
-
115
- - **Browser environment** (uses `Storage`, `window` navigation)
116
- - **TypeScript >= 5** (for type consumers; ships `.d.ts` files)
117
- - `amazon-cognito-identity-js` loaded as a browser global or importable module
118
-
119
- ---
120
-
121
- ## Quick start
122
-
123
- ```typescript
124
- import { CognitoClient } from '@clocklobster/cognito-client';
125
- // Load the SDK as a browser global, or via import:
126
- // import * as AmazonCognitoIdentity from 'amazon-cognito-identity-js';
127
-
128
- const cognito = new CognitoClient({
129
- // Pool configuration (plain strings or lazy suppliers)
130
- userPoolId: 'us-east-1_XXXXXXXXX',
131
- clientId: 'your-app-client-id',
132
-
133
- // The amazon-cognito-identity-js namespace
134
- sdk: AmazonCognitoIdentity,
135
-
136
- // Storage for the SDK's session (refresh token).
137
- // Use sessionStorage so tokens never touch localStorage and clear on tab close.
138
- storage: sessionStorage,
139
-
140
- // Map SDK errors to your app's error messages
141
- errorMapper: (err) => {
142
- const msg = err instanceof Error ? err.message : String(err);
143
- if (msg.includes('NotAuthorizedException')) {
144
- return new Error('Incorrect email or password.');
145
- }
146
- if (msg.includes('UserNotFoundException')) {
147
- return new Error('No account found with that email.');
148
- }
149
- return new Error(msg || 'Authentication failed.');
150
- },
151
-
152
- // Navigation (used by redirectToLogin)
153
- navigate: (url) => (window.location.href = url),
154
- getCurrentPath: () => window.location.pathname + window.location.search,
155
- });
156
-
157
- // Sign up
158
- await cognito.signUp('user@example.com', 'SecurePassword123!', [
159
- { Name: 'email', Value: 'user@example.com' },
160
- ]);
161
-
162
- // Confirm sign-up
163
- await cognito.confirmSignUp('user@example.com', '123456');
164
-
165
- // Sign in
166
- const result = await cognito.signIn('user@example.com', 'SecurePassword123!');
167
- if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
168
- // Collect a new password from the user, then:
169
- const tokens = await cognito.completeNewPassword('NewSecurePassword456!', {
170
- // Optional: required attribute updates from the challenge
171
- name: 'Jane',
172
- });
173
- console.log('Signed in:', tokens.idToken);
174
- } else {
175
- console.log('Signed in:', result.idToken);
176
- }
177
-
178
- // On page load (e.g. in your app bootstrap), restore the session:
179
- const session = await cognito.getSession();
180
- if (session) {
181
- console.log('Restored session for', session.user);
182
- console.log('ID token:', session.idToken);
183
- }
184
-
185
- // Sign out
186
- cognito.signOut();
187
- ```
188
-
189
- ---
190
-
191
- ## Configuration
192
-
193
- ### `CognitoClientOptions`
194
-
195
- | Option | Type | Required | Description |
196
- |---|---|---|---|
197
- | `userPoolId` | `string \| (() => string)` | yes | Cognito User Pool ID (e.g. `us-east-1_XXXXX`). Can be a lazy supplier resolved at first auth operation. |
198
- | `clientId` | `string \| (() => string)` | yes | Cognito App Client ID. Can be a lazy supplier. |
199
- | `sdk` | `CognitoSdk` | yes | The `amazon-cognito-identity-js` namespace (browser global or imported module). |
200
- | `storage` | `Storage \| (() => Storage \| undefined)` | yes | The `Storage` used for ALL SDK persistence. Bind `sessionStorage` so tokens never touch `localStorage`. Can be a lazy supplier. |
201
- | `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. |
202
- | `navigate` | `(url: string) => void` | yes | Navigation function used by `redirectToLogin`. Typically `(url) => window.location.href = url`. |
203
- | `getCurrentPath` | `() => string` | no | Returns the current page path + query, used to build `?returnTo=`. Defaults to empty string (no returnTo). |
204
-
205
- ### Lazy suppliers
206
-
207
- `userPoolId`, `clientId`, and `storage` accept either a plain value or a function. The
208
- function is resolved at first auth operation (not at construction), so apps that load
209
- config at runtime (e.g. from a fetched config endpoint) can supply a lazy supplier:
210
-
211
- ```typescript
212
- const cognito = new CognitoClient({
213
- userPoolId: () => appConfig.cognito.userPoolId,
214
- clientId: () => appConfig.cognito.clientId,
215
- // ...
216
- });
217
- ```
218
-
219
- ---
220
-
221
- ## API reference
222
-
223
- ### `CognitoClient`
224
-
225
- ```typescript
226
- import { CognitoClient } from '@clocklobster/cognito-client';
227
-
228
- const cognito = new CognitoClient(options);
229
- ```
230
-
231
- ---
232
-
233
- ### `signUp(email, password, attributeList?)`
234
-
235
- Registers a new user in the Cognito user pool.
236
-
237
- ```typescript
238
- const result = await cognito.signUp('user@example.com', 'Password123!', [
239
- { Name: 'email', Value: 'user@example.com' },
240
- { Name: 'phone_number', Value: '+14165551234' },
241
- ]);
242
- // result: { userConfirmed: boolean, userSub: string }
243
- ```
244
-
245
- - `attributeList` — array of `{ Name, Value }` Cognito attributes (defaults to `[]`)
246
- - Returns `{ userConfirmed, userSub }` `userConfirmed` is `false` if email/SMS
247
- verification is required before sign-in
248
-
249
- ---
250
-
251
- ### `confirmSignUp(email, code)`
252
-
253
- Confirms a sign-up using the verification code sent to the user's email/SMS.
254
-
255
- ```typescript
256
- await cognito.confirmSignUp('user@example.com', '123456');
257
- ```
258
-
259
- ---
260
-
261
- ### `signIn(email, password)`
262
-
263
- Authenticates a user. Returns either tokens (success) or a `NEW_PASSWORD_REQUIRED`
264
- challenge that must be completed via `completeNewPassword()`.
265
-
266
- ```typescript
267
- const result = await cognito.signIn('user@example.com', 'Password123!');
268
-
269
- if (result.challenge === null) {
270
- // Success tokens are available
271
- console.log(result.idToken, result.accessToken);
272
- } else if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
273
- // User must set a new permanent password
274
- console.log(result.userAttributes);
275
- console.log(result.requiredAttributes);
276
- }
277
- ```
278
-
279
- **Returns:** `SignInResult` — either:
280
- - `{ challenge: null, idToken: string, accessToken: string }`, or
281
- - `{ challenge: 'NEW_PASSWORD_REQUIRED', userAttributes, requiredAttributes }`
282
-
283
- On success, the session is persisted to the injected `Storage` so it survives the
284
- post-login redirect.
285
-
286
- ---
287
-
288
- ### `completeNewPassword(newPassword, userAttributes?)`
289
-
290
- Completes a `NEW_PASSWORD_REQUIRED` challenge issued by `signIn()`. Throws if no
291
- challenge is in flight.
292
-
293
- ```typescript
294
- const tokens = await cognito.completeNewPassword('NewPassword456!', {
295
- name: 'Jane Doe', // optional required attribute updates
296
- });
297
- // tokens: { idToken, accessToken }
298
- ```
299
-
300
- - `userAttributes` — any required attribute updates from the challenge. The `sub`
301
- attribute is automatically scrubbed (Cognito rejects resending it).
302
- - On success, tokens are stored and the challenge state is cleared.
303
- - On failure, the challenge state is cleared and the user must sign in again.
304
-
305
- ---
306
-
307
- ### `getSession()`
308
-
309
- Restores a cached session from the injected `Storage`. Used on page load to check
310
- if the user is already authenticated.
311
-
312
- ```typescript
313
- const session = await cognito.getSession();
314
- if (session) {
315
- console.log('User:', session.user);
316
- console.log('ID token:', session.idToken);
317
- console.log('Access token:', session.accessToken);
318
- } else {
319
- // Not authenticated — redirect to login
320
- cognito.redirectToLogin('/login.html');
321
- }
322
- ```
323
-
324
- **Returns:** `RestoredSession | null`
325
-
326
- - On success: `{ idToken, accessToken, user }`
327
- - On failure (stale/invalid session): signs out the SDK user, clears tokens, returns `null`
328
- - On synchronous SDK throw (no cached refresh token): clears tokens, returns `null`
329
-
330
- ---
331
-
332
- ### `refreshSession()`
333
-
334
- Refreshes the session using the cached refresh token. Safe to call even when the SDK's
335
- `signInUserSession` has not been loaded into memory yet (e.g. an API call races the
336
- page's own `getSession()` call).
337
-
338
- ```typescript
339
- const tokens = await cognito.refreshSession();
340
- // tokens: { idToken, accessToken }
341
- ```
342
-
343
- **Returns:** `SessionTokens` `{ idToken, accessToken }`
344
-
345
- **Throws:** if there is no cached session or the refresh token is invalid.
346
-
347
- ---
348
-
349
- ### `forgotPassword(email)`
350
-
351
- Initiates the forgot-password flow. Cognito sends a verification code to the user's
352
- email/SMS. Resolves when the code has been sent (the `inputVerificationCode` callback
353
- fires).
354
-
355
- ```typescript
356
- await cognito.forgotPassword('user@example.com');
357
- // Now prompt the user for the code + new password
358
- ```
359
-
360
- ---
361
-
362
- ### `confirmNewPassword(email, code, newPassword)`
363
-
364
- Completes the forgot-password flow by submitting the verification code and a new password.
365
-
366
- ```typescript
367
- await cognito.confirmNewPassword('user@example.com', '123456', 'NewPassword789!');
368
- ```
369
-
370
- ---
371
-
372
- ### `signOut()`
373
-
374
- Signs out the current user from the SDK and clears all token/challenge state.
375
-
376
- ```typescript
377
- cognito.signOut();
378
- ```
379
-
380
- Clears:
381
- - `idToken`, `accessToken`, `currentUser`
382
- - `pendingChallengeUser` (any in-flight `NEW_PASSWORD_REQUIRED` challenge)
383
-
384
- ---
385
-
386
- ### `redirectToLogin(loginUrl)`
387
-
388
- Signs out any stale Cognito session, then navigates to `loginUrl` with the current page
389
- preserved as `?returnTo=` so the login page can send the user back after sign-in.
390
-
391
- ```typescript
392
- cognito.redirectToLogin('/login.html');
393
- // Navigates to: /login.html?returnTo=%2Fdashboard%3Ftab%3Dsettings
394
- ```
395
-
396
- - `loginUrl` — the login page URL (product policy — the adapter owns this)
397
- - Signs out first so the login form doesn't pick up a cached user whose tokens are dead
398
- - Uses `getCurrentPath()` (if provided) to build the `?returnTo=` query parameter
399
-
400
- ---
401
-
402
- ### Token accessors
403
-
404
- | Method | Returns | Description |
405
- |---|---|---|
406
- | `getUser()` | `string \| null` | The current user's username (or `null` if not signed in) |
407
- | `getIdToken()` | `string \| null` | The current Cognito ID token JWT (or `null`) |
408
- | `getAccessToken()` | `string \| null` | The current Cognito access token JWT (or `null`) |
409
-
410
- These return `null` when no session is active. They read from in-memory state set by
411
- `signIn()`, `completeNewPassword()`, `getSession()`, or `refreshSession()`.
412
-
413
- ---
414
-
415
- ### Types
416
-
417
- ```typescript
418
- // SignIn result — either tokens (success) or a challenge
419
- type SignInResult =
420
- | { challenge: null; idToken: string; accessToken: string }
421
- | {
422
- challenge: 'NEW_PASSWORD_REQUIRED';
423
- userAttributes: Record<string, unknown>;
424
- requiredAttributes: Record<string, unknown>;
425
- };
426
-
427
- // Session tokens
428
- interface SessionTokens {
429
- idToken: string;
430
- accessToken: string;
431
- }
432
-
433
- // Restored session (from getSession)
434
- interface RestoredSession extends SessionTokens {
435
- user: string;
436
- }
437
-
438
- // The SDK surface the client uses (inject amazon-cognito-identity-js)
439
- interface CognitoSdk {
440
- CognitoUserPool: new (data: { UserPoolId: string; ClientId: string; Storage?: Storage }) => CognitoUserPoolLike;
441
- CognitoUser: new (data: { Username: string; Pool: CognitoUserPoolLike; Storage?: Storage }) => CognitoUserLike;
442
- AuthenticationDetails: new (data: { Username: string; Password: string }) => unknown;
443
- }
444
-
445
- // Client options
446
- interface CognitoClientOptions {
447
- userPoolId: string | (() => string);
448
- clientId: string | (() => string);
449
- sdk: CognitoSdk;
450
- storage: Storage | (() => Storage | undefined);
451
- errorMapper: (err: unknown) => Error;
452
- navigate: (url: string) => void;
453
- getCurrentPath?: () => string;
454
- }
455
- ```
456
-
457
- ---
458
-
459
- ## Dependency injection
460
-
461
- Every dependency is injected the client has zero hardcoded values:
462
-
463
- | Dependency | Purpose | Typical binding |
464
- |---|---|---|
465
- | `sdk` | The `amazon-cognito-identity-js` namespace | Browser global or `import * as` |
466
- | `userPoolId` | Cognito User Pool ID | String from config/env |
467
- | `clientId` | Cognito App Client ID | String from config/env |
468
- | `storage` | SDK session persistence | `sessionStorage` (never `localStorage`) |
469
- | `errorMapper` | SDK error → user-facing error | Your app's error message map |
470
- | `navigate` | Page navigation for `redirectToLogin` | `(url) => window.location.href = url` |
471
- | `getCurrentPath` | Current path for `?returnTo=` | `() => window.location.pathname + window.location.search` |
472
-
473
- This means:
474
- - **No hardcoded pool IDs** — different environments (dev/staging/prod) inject different pools
475
- - **No hardcoded error messages** — your app owns the UX copy
476
- - **No hardcoded routes** — your app owns the login URL and redirect logic
477
- - **No hardcoded storage** — bind `sessionStorage` for tab-scoped sessions, or a custom
478
- `Storage` implementation for testing
479
-
480
- ---
481
-
482
- ## The NEW_PASSWORD_REQUIRED challenge
483
-
484
- When a user signs in and Cognito requires a new permanent password (e.g. admin-created/
485
- invited users in `FORCE_CHANGE_PASSWORD` state), `signIn()` does **not** throw an error.
486
- Instead, it returns a challenge result:
487
-
488
- ```typescript
489
- const result = await cognito.signIn(email, tempPassword);
490
-
491
- if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
492
- // The user is authenticated but must set a new password.
493
- // Show a "set new password" form, then:
494
- const tokens = await cognito.completeNewPassword(newPassword, {
495
- name: 'Jane', // optional required attributes
496
- });
497
- // tokens.idToken / tokens.accessToken are now available
498
- }
499
- ```
500
-
501
- Key details:
502
- - The `CognitoUser` reference is held internally during the challenge — you don't need
503
- to re-authenticate to complete it
504
- - The `sub` attribute is automatically scrubbed from `userAttributes` (Cognito rejects
505
- resending it it's read-only/server-managed)
506
- - On success, tokens are stored and the challenge state is cleared
507
- - On failure, the challenge state is cleared and the user must sign in again
508
-
509
- ---
510
-
511
- ## Session persistence and the post-login redirect
512
-
513
- Cognito's SDK persists the session (refresh token) to the injected `Storage`. This
514
- client binds `sessionStorage` by convention so:
515
-
516
- - **Runtime tokens live in memory only** — `idToken` / `accessToken` are never written
517
- to storage; they're held in the `CognitoClient` instance
518
- - **The SDK session (refresh token) lives in `sessionStorage`** — so it survives the
519
- post-login redirect but clears when the tab closes
520
- - **On page load**, call `getSession()` to restore the session from `sessionStorage`
521
-
522
- ```typescript
523
- // App bootstrap (every page load):
524
- const session = await cognito.getSession();
525
- if (session) {
526
- // User is authenticated — render the app
527
- initApp(session);
528
- } else {
529
- // Not authenticated redirect to login
530
- cognito.redirectToLogin('/login.html');
531
- }
532
-
533
- // Login page (after successful signIn):
534
- const result = await cognito.signIn(email, password);
535
- if (result.challenge === null) {
536
- // Read ?returnTo= from the URL and navigate there
537
- const returnTo = new URLSearchParams(window.location.search).get('returnTo') || '/';
538
- window.location.href = returnTo;
539
- }
540
- ```
541
-
542
- ---
543
-
544
- ## Security model
545
-
546
- - **Runtime tokens in memory only** — `idToken` and `accessToken` are never written to
547
- `Storage`. They live in the `CognitoClient` instance and are cleared on sign-out or
548
- terminal failure.
549
- - **SDK session in `sessionStorage`** the refresh token persists in the injected
550
- `Storage` (bind `sessionStorage`, not `localStorage`, so it clears on tab close).
551
- - **Terminal failure clears state** — a stale/invalid cached session triggers `signOut()`
552
- + `clearTokens()`, so no dead token state survives.
553
- - **Sign-out is thorough** — calls `cognitoUser.signOut()` on the SDK AND clears all
554
- in-memory token/challenge state.
555
- - **`redirectToLogin` signs out first** — so the login form doesn't pick up a cached
556
- user whose tokens are dead.
557
- - **`sub` is scrubbed** `completeNewPassword` strips the `sub` attribute from
558
- `userAttributes` before sending (Cognito rejects resending it).
559
-
560
- ---
561
-
562
- ## Testing
563
-
564
- The suite uses [Vitest](https://vitest.dev/) with a `jsdom` environment and a mock SDK.
565
- 19 tests across 4 describe blocks:
566
-
567
- | Describe block | Tests | Coverage |
568
- |---|---|---|
569
- | `CognitoClient - dependency injection` | 6 | Lazy pool config, lazy storage, SDK injection, error mapper |
570
- | `CognitoClient - signIn / session` | 8 | signIn success, NEW_PASSWORD_REQUIRED challenge, completeNewPassword, getSession, refreshSession, forgotPassword, confirmNewPassword |
571
- | `CognitoClient - sign-out / navigation` | 3 | signOut, redirectToLogin with ?returnTo=, stale session cleanup |
572
- | `CognitoClient - product neutrality` | 2 | No product roles/routes on the prototype, no product terms in source |
573
-
574
- ```bash
575
- npm test # vitest run (jsdom, mock SDK no real Cognito calls)
576
- ```
577
-
578
- The **product-neutrality test** asserts that the `CognitoClient` source contains no
579
- product-specific terms (roles, routes, copy) — this guarantees the core stays generic
580
- as it evolves.
581
-
582
- ---
583
-
584
- ## Development
585
-
586
- ```bash
587
- # Install dependencies
588
- pnpm install
589
-
590
- # Typecheck
591
- pnpm run typecheck # tsc --noEmit
592
-
593
- # Run tests (jsdom + mock SDK no real Cognito)
594
- pnpm test # vitest run
595
-
596
- # Build (emit to dist/)
597
- pnpm run build # tsc -p tsconfig.build.json
598
- ```
599
-
600
- ### Requirements
601
-
602
- - Node.js >= 18
603
- - pnpm (or npm/yarnthe package has no runtime dependencies)
604
- - TypeScript >= 5
605
-
606
- ---
607
-
608
- ## Project layout
609
-
610
- ```text
611
- cognito-client/
612
- ├── src/
613
- │ └── index.ts # CognitoClient + all types (single file, ~400 lines)
614
- ├── test/
615
- │ └── cognito-client.test.ts # 19 tests — DI, signIn/session, sign-out/nav, neutrality
616
- ├── package.json
617
- ├── tsconfig.json
618
- ├── tsconfig.build.json
619
- ├── vitest.config.ts
620
- ├── LICENSE
621
- └── README.md
622
- ```
623
-
624
- ---
625
-
626
- ## Comparison with Amplify
627
-
628
- | | `cognito-client` | AWS Amplify Auth |
629
- |---|---|---|
630
- | **Dependencies** | Zero runtime (you inject the SDK) | Heavyweight (~50 deps) |
631
- | **Bundle size** | ~4KB (your code only) | ~100KB+ |
632
- | **Error messages** | You own them (`errorMapper`) | Amplify's defaults |
633
- | **Routes** | You own them (`navigate`) | Amplify's hosted UI / config |
634
- | **Storage** | You choose (`sessionStorage` recommended) | Amplify's `localStorage` default |
635
- | **Pool config** | String or lazy supplier | Static config at init |
636
- | **NEW_PASSWORD_REQUIRED** | First-class challenge result | Handled internally |
637
- | **Product coupling** | None (tested) | Amplify ecosystem assumptions |
638
- | **TypeScript** | Full types, strict | Full types |
639
-
640
- **When to use `cognito-client`:** you want a thin, typed, dependency-injected Cognito
641
- wrapper that you fully control. You own the UX, the error messages, the storage strategy,
642
- and the routing.
643
-
644
- **When to use Amplify:** you want a batteries-included auth solution with hosted UI,
645
- social providers, MFA, and the full Amplify ecosystem — and you're OK with the bundle
646
- size and opinionated defaults.
647
-
648
- ---
649
-
650
- ## Contributing
651
-
652
- Pull requests are welcome.
653
-
654
- ### Guidelines
655
-
656
- 1. Add or update tests for any change (Vitest, jsdom, mock SDK).
657
- 2. Ensure `pnpm run typecheck` and `pnpm test` pass.
658
- 3. Do not commit secrets, `.env` files, or `dist/` output.
659
- 4. Follow the existing code style (strict TypeScript, no `any`, dependency injection).
660
- 5. **Keep the core product-neutral** the product-neutrality test must stay green. No
661
- product roles, routes, or copy in `src/index.ts`.
662
-
663
- ---
664
-
665
- ## License
666
-
667
- [MIT](LICENSE) © Victor Salmon
1
+ # cognito-client
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue.svg)](https://www.typescriptlang.org/)
5
+ [![Tests](https://img.shields.io/badge/tests-41%20passing-brightgreen.svg)](#testing)
6
+
7
+ A generic, dependency-injected browser client for [AWS Cognito](https://aws.amazon.com/cognito/)
8
+ user pools — sign-up, confirmation, sign-in, session restore/refresh, sign-out, forgot/reset
9
+ password, and the `NEW_PASSWORD_REQUIRED` challenge. **No product coupling, no hardcoded
10
+ routes, no default targets — every dependency is injected.**
11
+
12
+ > **Why this exists:** AWS Amplify Auth is heavyweight and opinionated. The raw
13
+ > `amazon-cognito-identity-js` SDK is callback-based and untyped. This client wraps the
14
+ > SDK into a clean, typed, Promise-based surface with every product concern (storage,
15
+ > error messages, navigation, pool config) injected — so you own the UX and policy, and
16
+ > the client owns the Cognito mechanics.
17
+
18
+ ---
19
+
20
+ ## Table of contents
21
+
22
+ - [Overview](#overview)
23
+ - [Features](#features)
24
+ - [Install](#install)
25
+ - [Quick start](#quick-start)
26
+ - [Configuration](#configuration)
27
+ - [API reference](#api-reference)
28
+ - [`CognitoClient`](#cognitoclient)
29
+ - [`signUp(email, password, attributeList?)`](#signupemail-password-attributelist)
30
+ - [`confirmSignUp(email, code)`](#confirmsignupemail-code)
31
+ - [`signIn(email, password)`](#signinemail-password)
32
+ - [`completeNewPassword(newPassword, userAttributes?)`](#completenewpasswordnewpassword-userattributes)
33
+ - [`getSession()`](#getsession)
34
+ - [`ensureSession(loginUrl)`](#ensuresessionloginurl)
35
+ - [`refreshSession()`](#refreshsession)
36
+ - [`forgotPassword(email)`](#forgotpasswordemail)
37
+ - [`confirmNewPassword(email, code, newPassword)`](#confirmnewpasswordemail-code-newpassword)
38
+ - [`signOut()`](#signout)
39
+ - [`redirectToLogin(loginUrl)`](#redirecttologinloginurl)
40
+ - [Token accessors](#token-accessors)
41
+ - [Types](#types)
42
+ - [Dependency injection](#dependency-injection)
43
+ - [The NEW_PASSWORD_REQUIRED challenge](#the-new_password_required-challenge)
44
+ - [Session persistence and the post-login redirect](#session-persistence-and-the-post-login-redirect)
45
+ - [Security model](#security-model)
46
+ - [Testing](#testing)
47
+ - [Development](#development)
48
+ - [Project layout](#project-layout)
49
+ - [Comparison with Amplify](#comparison-with-amplify)
50
+ - [Contributing](#contributing)
51
+ - [License](#license)
52
+
53
+ ---
54
+
55
+ ## Overview
56
+
57
+ `cognito-client` provides a product-neutral Cognito lifecycle for browser applications.
58
+ It wraps the `amazon-cognito-identity-js` SDK (loaded as a browser global or injected as
59
+ a mock) behind a typed, Promise-based interface:
60
+
61
+ - **Sign-up** + email confirmation
62
+ - **Sign-in** with `NEW_PASSWORD_REQUIRED` challenge support
63
+ - **Session restore** (survives page reload / post-login redirect)
64
+ - **Session refresh** (uses cached refresh token)
65
+ - **Forgot password** + confirm new password
66
+ - **Sign-out** (clears tokens + SDK session)
67
+ - **Redirect to login** with `?returnTo=` preservation
68
+
69
+ Every product concern is injected:
70
+ - `sdk` the `amazon-cognito-identity-js` namespace
71
+ - `userPoolId` / `clientId` pool configuration (plain strings or lazy suppliers)
72
+ - `storage` — the `Storage` used for SDK persistence (bind `sessionStorage` so tokens
73
+ never touch `localStorage`)
74
+ - `errorMapper` maps SDK errors to your product's error copy
75
+ - `navigate` / `getCurrentPath` — navigation hooks for `redirectToLogin`
76
+
77
+ ---
78
+
79
+ ## Features
80
+
81
+ - **Fully dependency-injected** no hardcoded pool IDs, no hardcoded routes, no hardcoded
82
+ error messages. You own the product policy; the client owns the Cognito mechanics.
83
+ - **Promise-based** — wraps the callback-based SDK into clean async/await
84
+ - **TypeScript-native** — full types for every method, option, and SDK interface
85
+ - **NEW_PASSWORD_REQUIRED challenge** — surfaced as a `SignInResult.challenge`, not an error
86
+ - **Session persistence** — tokens survive the post-login redirect via the injected `Storage`
87
+ - **Lazy pool config** — `userPoolId` / `clientId` can be functions resolved at first use
88
+ (for apps that load config at runtime)
89
+ - **Token safety** runtime tokens live in memory only; the SDK session (refresh token)
90
+ lives in the injected `Storage` (use `sessionStorage` so it clears on tab close)
91
+ - **Product-neutrality tested** — a test asserts the core source contains no product
92
+ roles, routes, or copy
93
+ - **Zero runtime dependencies** — only dev dependencies (TypeScript, Vitest, jsdom)
94
+
95
+ ---
96
+
97
+ ## Install
98
+
99
+ ```bash
100
+ npm install @clocklobster/cognito-client
101
+ # or
102
+ pnpm add @clocklobster/cognito-client
103
+ ```
104
+
105
+ ### Peer requirement
106
+
107
+ This client wraps [amazon-cognito-identity-js](https://www.npmjs.com/package/amazon-cognito-identity-js).
108
+ Install it in your app and pass the SDK namespace to the client constructor:
109
+
110
+ ```bash
111
+ npm install amazon-cognito-identity-js
112
+ ```
113
+
114
+ ### Requirements
115
+
116
+ - **Browser environment** (uses `Storage`, `window` navigation)
117
+ - **TypeScript >= 5** (for type consumers; ships `.d.ts` files)
118
+ - `amazon-cognito-identity-js` loaded as a browser global or importable module
119
+
120
+ ---
121
+
122
+ ## Quick start
123
+
124
+ ```typescript
125
+ import { CognitoClient } from '@clocklobster/cognito-client';
126
+ // Load the SDK — as a browser global, or via import:
127
+ // import * as AmazonCognitoIdentity from 'amazon-cognito-identity-js';
128
+
129
+ const cognito = new CognitoClient({
130
+ // Pool configuration (plain strings or lazy suppliers)
131
+ userPoolId: 'us-east-1_XXXXXXXXX',
132
+ clientId: 'your-app-client-id',
133
+
134
+ // The amazon-cognito-identity-js namespace
135
+ sdk: AmazonCognitoIdentity,
136
+
137
+ // Storage for the SDK's session (refresh token).
138
+ // Use sessionStorage so tokens never touch localStorage and clear on tab close.
139
+ storage: sessionStorage,
140
+
141
+ // Map SDK errors to your app's error messages
142
+ errorMapper: (err) => {
143
+ const msg = err instanceof Error ? err.message : String(err);
144
+ if (msg.includes('NotAuthorizedException')) {
145
+ return new Error('Incorrect email or password.');
146
+ }
147
+ if (msg.includes('UserNotFoundException')) {
148
+ return new Error('No account found with that email.');
149
+ }
150
+ return new Error(msg || 'Authentication failed.');
151
+ },
152
+
153
+ // Navigation (used by redirectToLogin)
154
+ navigate: (url) => (window.location.href = url),
155
+ getCurrentPath: () => window.location.pathname + window.location.search,
156
+ });
157
+
158
+ // Sign up
159
+ await cognito.signUp('user@example.com', 'SecurePassword123!', [
160
+ { Name: 'email', Value: 'user@example.com' },
161
+ ]);
162
+
163
+ // Confirm sign-up
164
+ await cognito.confirmSignUp('user@example.com', '123456');
165
+
166
+ // Sign in
167
+ const result = await cognito.signIn('user@example.com', 'SecurePassword123!');
168
+ if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
169
+ // Collect a new password from the user, then:
170
+ const tokens = await cognito.completeNewPassword('NewSecurePassword456!', {
171
+ // Optional: required attribute updates from the challenge
172
+ name: 'Jane',
173
+ });
174
+ console.log('Signed in:', tokens.idToken);
175
+ } else {
176
+ console.log('Signed in:', result.idToken);
177
+ }
178
+
179
+ // On page load (e.g. in your app bootstrap), restore the session:
180
+ const session = await cognito.getSession();
181
+ if (session) {
182
+ console.log('Restored session for', session.user);
183
+ console.log('ID token:', session.idToken);
184
+ }
185
+
186
+ // Sign out
187
+ cognito.signOut();
188
+ ```
189
+
190
+ ---
191
+
192
+ ## Configuration
193
+
194
+ ### `CognitoClientOptions`
195
+
196
+ | Option | Type | Required | Description |
197
+ |---|---|---|---|
198
+ | `userPoolId` | `string \| (() => string)` | yes | Cognito User Pool ID (e.g. `us-east-1_XXXXX`). Can be a lazy supplier resolved at first auth operation. |
199
+ | `clientId` | `string \| (() => string)` | yes | Cognito App Client ID. Can be a lazy supplier. |
200
+ | `sdk` | `CognitoSdk` | yes | The `amazon-cognito-identity-js` namespace (browser global or imported module). |
201
+ | `storage` | `Storage \| (() => Storage \| undefined)` | yes | The `Storage` used for ALL SDK persistence. Bind `sessionStorage` so tokens never touch `localStorage`. Can be a lazy supplier. |
202
+ | `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. |
203
+ | `navigate` | `(url: string) => void` | yes | Navigation function used by `redirectToLogin`. Typically `(url) => window.location.href = url`. |
204
+ | `getCurrentPath` | `() => string` | no | Returns the current page path + query, used to build `?returnTo=`. Defaults to empty string (no returnTo). |
205
+
206
+ ### Lazy suppliers
207
+
208
+ `userPoolId`, `clientId`, and `storage` accept either a plain value or a function. The
209
+ function is resolved at first auth operation (not at construction), so apps that load
210
+ config at runtime (e.g. from a fetched config endpoint) can supply a lazy supplier:
211
+
212
+ ```typescript
213
+ const cognito = new CognitoClient({
214
+ userPoolId: () => appConfig.cognito.userPoolId,
215
+ clientId: () => appConfig.cognito.clientId,
216
+ // ...
217
+ });
218
+ ```
219
+
220
+ ---
221
+
222
+ ## API reference
223
+
224
+ ### `CognitoClient`
225
+
226
+ ```typescript
227
+ import { CognitoClient } from '@clocklobster/cognito-client';
228
+
229
+ const cognito = new CognitoClient(options);
230
+ ```
231
+
232
+ ---
233
+
234
+ ### `signUp(email, password, attributeList?)`
235
+
236
+ Registers a new user in the Cognito user pool.
237
+
238
+ ```typescript
239
+ const result = await cognito.signUp('user@example.com', 'Password123!', [
240
+ { Name: 'email', Value: 'user@example.com' },
241
+ { Name: 'phone_number', Value: '+14165551234' },
242
+ ]);
243
+ // result: { userConfirmed: boolean, userSub: string }
244
+ ```
245
+
246
+ - `attributeList` — array of `{ Name, Value }` Cognito attributes (defaults to `[]`)
247
+ - Returns `{ userConfirmed, userSub }` — `userConfirmed` is `false` if email/SMS
248
+ verification is required before sign-in
249
+
250
+ ---
251
+
252
+ ### `confirmSignUp(email, code)`
253
+
254
+ Confirms a sign-up using the verification code sent to the user's email/SMS.
255
+
256
+ ```typescript
257
+ await cognito.confirmSignUp('user@example.com', '123456');
258
+ ```
259
+
260
+ ---
261
+
262
+ ### `signIn(email, password)`
263
+
264
+ Authenticates a user. Returns either tokens (success) or a `NEW_PASSWORD_REQUIRED`
265
+ challenge that must be completed via `completeNewPassword()`.
266
+
267
+ ```typescript
268
+ const result = await cognito.signIn('user@example.com', 'Password123!');
269
+
270
+ if (result.challenge === null) {
271
+ // Success — tokens are available
272
+ console.log(result.idToken, result.accessToken);
273
+ } else if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
274
+ // User must set a new permanent password
275
+ console.log(result.userAttributes);
276
+ console.log(result.requiredAttributes);
277
+ }
278
+ ```
279
+
280
+ **Returns:** `SignInResult` either:
281
+ - `{ challenge: null, idToken: string, accessToken: string }`, or
282
+ - `{ challenge: 'NEW_PASSWORD_REQUIRED', userAttributes, requiredAttributes }`
283
+
284
+ On success, the session is persisted to the injected `Storage` so it survives the
285
+ post-login redirect.
286
+
287
+ ---
288
+
289
+ ### `completeNewPassword(newPassword, userAttributes?)`
290
+
291
+ Completes a `NEW_PASSWORD_REQUIRED` challenge issued by `signIn()`. Throws if no
292
+ challenge is in flight.
293
+
294
+ ```typescript
295
+ const tokens = await cognito.completeNewPassword('NewPassword456!', {
296
+ name: 'Jane Doe', // optional required attribute updates
297
+ });
298
+ // tokens: { idToken, accessToken }
299
+ ```
300
+
301
+ - `userAttributes` any required attribute updates from the challenge. The `sub`
302
+ attribute is automatically scrubbed (Cognito rejects resending it).
303
+ - On success, tokens are stored and the challenge state is cleared.
304
+ - On failure, the challenge state is cleared and the user must sign in again.
305
+
306
+ ---
307
+
308
+ ### `getSession()`
309
+
310
+ Restores a cached session from the injected `Storage`. Used on page load to check
311
+ if the user is already authenticated.
312
+
313
+ ```typescript
314
+ const session = await cognito.getSession();
315
+ if (session) {
316
+ console.log('User:', session.user);
317
+ console.log('ID token:', session.idToken);
318
+ console.log('Access token:', session.accessToken);
319
+ } else {
320
+ // Not authenticated — redirect to login
321
+ cognito.redirectToLogin('/login.html');
322
+ }
323
+ ```
324
+
325
+ **Returns:** `RestoredSession | null`
326
+
327
+ - On success: `{ idToken, accessToken, user }`
328
+ - On failure (stale/invalid session): signs out the SDK user, clears tokens, returns `null`
329
+ - On synchronous SDK throw (no cached refresh token): clears tokens, returns `null`
330
+
331
+ ---
332
+
333
+ ### `ensureSession(loginUrl)`
334
+
335
+ Canonical async page-load gate for protected pages. Unlike a sync token-presence
336
+ check (which passes with a stale-but-cached token after credentials timeout,
337
+ letting the page fetch and render private data before the 401 path discovers
338
+ the dead session), this validates the session through the SDK first.
339
+
340
+ ```typescript
341
+ const session = await cognito.ensureSession('/login.html');
342
+ if (!session) return; // dead session — already redirected to login
343
+ // live session (refreshed via the stored refresh token when needed)
344
+ console.log('ID token:', session.idToken);
345
+ ```
346
+
347
+ **Returns:** `RestoredSession | null` — never throws.
348
+
349
+ - Live session: returned as `{ idToken, accessToken, user }`, refreshing via
350
+ the stored refresh token when the id token expired but the refresh token is
351
+ still alive (seamless, no redirect).
352
+ - Dead session: signs out the stale SDK state, redirects to `loginUrl` (with
353
+ `?returnTo=` preservation), returns `null` — before any API fetch fires.
354
+
355
+ ### `refreshSession()`
356
+
357
+ Refreshes the session using the cached refresh token. Safe to call even when the SDK's
358
+ `signInUserSession` has not been loaded into memory yet (e.g. an API call races the
359
+ page's own `getSession()` call).
360
+
361
+ ```typescript
362
+ const tokens = await cognito.refreshSession();
363
+ // tokens: { idToken, accessToken }
364
+ ```
365
+
366
+ **Returns:** `SessionTokens` — `{ idToken, accessToken }`
367
+
368
+ **Throws:** if there is no cached session or the refresh token is invalid.
369
+
370
+ ---
371
+
372
+ ### `forgotPassword(email)`
373
+
374
+ Initiates the forgot-password flow. Cognito sends a verification code to the user's
375
+ email/SMS. Resolves when the code has been sent (the `inputVerificationCode` callback
376
+ fires).
377
+
378
+ ```typescript
379
+ await cognito.forgotPassword('user@example.com');
380
+ // Now prompt the user for the code + new password
381
+ ```
382
+
383
+ ---
384
+
385
+ ### `confirmNewPassword(email, code, newPassword)`
386
+
387
+ Completes the forgot-password flow by submitting the verification code and a new password.
388
+
389
+ ```typescript
390
+ await cognito.confirmNewPassword('user@example.com', '123456', 'NewPassword789!');
391
+ ```
392
+
393
+ ---
394
+
395
+ ### `signOut()`
396
+
397
+ Signs out the current user from the SDK and clears all token/challenge state.
398
+
399
+ ```typescript
400
+ cognito.signOut();
401
+ ```
402
+
403
+ Clears:
404
+ - `idToken`, `accessToken`, `currentUser`
405
+ - `pendingChallengeUser` (any in-flight `NEW_PASSWORD_REQUIRED` challenge)
406
+
407
+ ---
408
+
409
+ ### `redirectToLogin(loginUrl)`
410
+
411
+ Signs out any stale Cognito session, then navigates to `loginUrl` with the current page
412
+ preserved as `?returnTo=` so the login page can send the user back after sign-in.
413
+
414
+ ```typescript
415
+ cognito.redirectToLogin('/login.html');
416
+ // Navigates to: /login.html?returnTo=%2Fdashboard%3Ftab%3Dsettings
417
+ ```
418
+
419
+ - `loginUrl` — the login page URL (product policy — the adapter owns this)
420
+ - Signs out first so the login form doesn't pick up a cached user whose tokens are dead
421
+ - Uses `getCurrentPath()` (if provided) to build the `?returnTo=` query parameter
422
+
423
+ ---
424
+
425
+ ### Token accessors
426
+
427
+ | Method | Returns | Description |
428
+ |---|---|---|
429
+ | `getUser()` | `string \| null` | The current user's username (or `null` if not signed in) |
430
+ | `getIdToken()` | `string \| null` | The current Cognito ID token JWT (or `null`) |
431
+ | `getAccessToken()` | `string \| null` | The current Cognito access token JWT (or `null`) |
432
+
433
+ These return `null` when no session is active. They read from in-memory state set by
434
+ `signIn()`, `completeNewPassword()`, `getSession()`, or `refreshSession()`.
435
+
436
+ ---
437
+
438
+ ### Types
439
+
440
+ ```typescript
441
+ // SignIn result either tokens (success) or a challenge
442
+ type SignInResult =
443
+ | { challenge: null; idToken: string; accessToken: string }
444
+ | {
445
+ challenge: 'NEW_PASSWORD_REQUIRED';
446
+ userAttributes: Record<string, unknown>;
447
+ requiredAttributes: Record<string, unknown>;
448
+ };
449
+
450
+ // Session tokens
451
+ interface SessionTokens {
452
+ idToken: string;
453
+ accessToken: string;
454
+ }
455
+
456
+ // Restored session (from getSession)
457
+ interface RestoredSession extends SessionTokens {
458
+ user: string;
459
+ }
460
+
461
+ // The SDK surface the client uses (inject amazon-cognito-identity-js)
462
+ interface CognitoSdk {
463
+ CognitoUserPool: new (data: { UserPoolId: string; ClientId: string; Storage?: Storage }) => CognitoUserPoolLike;
464
+ CognitoUser: new (data: { Username: string; Pool: CognitoUserPoolLike; Storage?: Storage }) => CognitoUserLike;
465
+ AuthenticationDetails: new (data: { Username: string; Password: string }) => unknown;
466
+ }
467
+
468
+ // Client options
469
+ interface CognitoClientOptions {
470
+ userPoolId: string | (() => string);
471
+ clientId: string | (() => string);
472
+ sdk: CognitoSdk;
473
+ storage: Storage | (() => Storage | undefined);
474
+ errorMapper: (err: unknown) => Error;
475
+ navigate: (url: string) => void;
476
+ getCurrentPath?: () => string;
477
+ }
478
+ ```
479
+
480
+ ---
481
+
482
+ ## Dependency injection
483
+
484
+ Every dependency is injected the client has zero hardcoded values:
485
+
486
+ | Dependency | Purpose | Typical binding |
487
+ |---|---|---|
488
+ | `sdk` | The `amazon-cognito-identity-js` namespace | Browser global or `import * as` |
489
+ | `userPoolId` | Cognito User Pool ID | String from config/env |
490
+ | `clientId` | Cognito App Client ID | String from config/env |
491
+ | `storage` | SDK session persistence | `sessionStorage` (never `localStorage`) |
492
+ | `errorMapper` | SDK error → user-facing error | Your app's error message map |
493
+ | `navigate` | Page navigation for `redirectToLogin` | `(url) => window.location.href = url` |
494
+ | `getCurrentPath` | Current path for `?returnTo=` | `() => window.location.pathname + window.location.search` |
495
+
496
+ This means:
497
+ - **No hardcoded pool IDs** — different environments (dev/staging/prod) inject different pools
498
+ - **No hardcoded error messages** — your app owns the UX copy
499
+ - **No hardcoded routes** — your app owns the login URL and redirect logic
500
+ - **No hardcoded storage** — bind `sessionStorage` for tab-scoped sessions, or a custom
501
+ `Storage` implementation for testing
502
+
503
+ ---
504
+
505
+ ## The NEW_PASSWORD_REQUIRED challenge
506
+
507
+ When a user signs in and Cognito requires a new permanent password (e.g. admin-created/
508
+ invited users in `FORCE_CHANGE_PASSWORD` state), `signIn()` does **not** throw an error.
509
+ Instead, it returns a challenge result:
510
+
511
+ ```typescript
512
+ const result = await cognito.signIn(email, tempPassword);
513
+
514
+ if (result.challenge === 'NEW_PASSWORD_REQUIRED') {
515
+ // The user is authenticated but must set a new password.
516
+ // Show a "set new password" form, then:
517
+ const tokens = await cognito.completeNewPassword(newPassword, {
518
+ name: 'Jane', // optional required attributes
519
+ });
520
+ // tokens.idToken / tokens.accessToken are now available
521
+ }
522
+ ```
523
+
524
+ Key details:
525
+ - The `CognitoUser` reference is held internally during the challenge — you don't need
526
+ to re-authenticate to complete it
527
+ - The `sub` attribute is automatically scrubbed from `userAttributes` (Cognito rejects
528
+ resending it — it's read-only/server-managed)
529
+ - On success, tokens are stored and the challenge state is cleared
530
+ - On failure, the challenge state is cleared and the user must sign in again
531
+
532
+ ---
533
+
534
+ ## Session persistence and the post-login redirect
535
+
536
+ Cognito's SDK persists the session (refresh token) to the injected `Storage`. This
537
+ client binds `sessionStorage` by convention so:
538
+
539
+ - **Runtime tokens live in memory only** — `idToken` / `accessToken` are never written
540
+ to storage; they're held in the `CognitoClient` instance
541
+ - **The SDK session (refresh token) lives in `sessionStorage`** — so it survives the
542
+ post-login redirect but clears when the tab closes
543
+ - **On page load**, call `getSession()` to restore the session from `sessionStorage`
544
+
545
+ ```typescript
546
+ // App bootstrap (every page load):
547
+ const session = await cognito.getSession();
548
+ if (session) {
549
+ // User is authenticatedrender the app
550
+ initApp(session);
551
+ } else {
552
+ // Not authenticated redirect to login
553
+ cognito.redirectToLogin('/login.html');
554
+ }
555
+
556
+ // Login page (after successful signIn):
557
+ const result = await cognito.signIn(email, password);
558
+ if (result.challenge === null) {
559
+ // Read ?returnTo= from the URL and navigate there
560
+ const returnTo = new URLSearchParams(window.location.search).get('returnTo') || '/';
561
+ window.location.href = returnTo;
562
+ }
563
+ ```
564
+
565
+ ---
566
+
567
+ ## Security model
568
+
569
+ - **Runtime tokens in memory only** `idToken` and `accessToken` are never written to
570
+ `Storage`. They live in the `CognitoClient` instance and are cleared on sign-out or
571
+ terminal failure.
572
+ - **SDK session in `sessionStorage`** the refresh token persists in the injected
573
+ `Storage` (bind `sessionStorage`, not `localStorage`, so it clears on tab close).
574
+ - **Terminal failure clears state** — a stale/invalid cached session triggers `signOut()`
575
+ + `clearTokens()`, so no dead token state survives.
576
+ - **Sign-out is thorough** — calls `cognitoUser.signOut()` on the SDK AND clears all
577
+ in-memory token/challenge state.
578
+ - **`redirectToLogin` signs out first** so the login form doesn't pick up a cached
579
+ user whose tokens are dead.
580
+ - **`sub` is scrubbed** — `completeNewPassword` strips the `sub` attribute from
581
+ `userAttributes` before sending (Cognito rejects resending it).
582
+
583
+ ---
584
+
585
+ ## Testing
586
+
587
+ The suite uses [Vitest](https://vitest.dev/) with a `jsdom` environment and a mock SDK.
588
+ 41 tests across 5 describe blocks:
589
+
590
+ | Describe block | Tests | Coverage |
591
+ |---|---|---|
592
+ | `CognitoClient - dependency injection` | 5 | Lazy pool config, lazy storage, SDK injection, error mapper |
593
+ | `CognitoClient - signIn / session` | 9 | signIn success, NEW_PASSWORD_REQUIRED challenge, completeNewPassword, getSession, refreshSession, forgotPassword, confirmNewPassword |
594
+ | `CognitoClient - sign-out / navigation` | 3 | signOut, redirectToLogin with ?returnTo=, stale session cleanup |
595
+ | `CognitoClient - product neutrality` | 2 | No product roles/routes on the prototype, no product terms in source |
596
+ | `CognitoClient - property tests` | 22 | Invariants over generated inputs (tokens, scrubbing, lazy config, redirect, sign-up, errors) |
597
+
598
+ ```bash
599
+ npm test # vitest run (jsdom, mock SDK — no real Cognito calls)
600
+ ```
601
+
602
+ The **product-neutrality test** asserts that the `CognitoClient` source contains no
603
+ product-specific terms (roles, routes, copy) this guarantees the core stays generic
604
+ as it evolves.
605
+
606
+ ---
607
+
608
+ ## Development
609
+
610
+ ```bash
611
+ # Install dependencies
612
+ pnpm install
613
+
614
+ # Typecheck
615
+ pnpm run typecheck # tsc --noEmit
616
+
617
+ # Run tests (jsdom + mock SDK — no real Cognito)
618
+ pnpm test # vitest run
619
+
620
+ # Build (emit to dist/)
621
+ pnpm run build # tsc -p tsconfig.build.json
622
+ ```
623
+
624
+ ### Requirements
625
+
626
+ - Node.js >= 18
627
+ - pnpm (or npm/yarn — the package has no runtime dependencies)
628
+ - TypeScript >= 5
629
+
630
+ ---
631
+
632
+ ## Project layout
633
+
634
+ ```text
635
+ cognito-client/
636
+ ├── src/
637
+ │ └── index.ts # CognitoClient + all types (single file, ~400 lines)
638
+ ├── test/
639
+ │ ├── cognito-client.test.ts # 19 unit tests
640
+ │ └── cognito-client.property.test.ts # 22 property tests
641
+ ├── package.json
642
+ ├── tsconfig.json
643
+ ├── tsconfig.build.json
644
+ ├── vitest.config.ts
645
+ ├── LICENSE
646
+ └── README.md
647
+ ```
648
+
649
+ ---
650
+
651
+ ## Comparison with Amplify
652
+
653
+ | | `cognito-client` | AWS Amplify Auth |
654
+ |---|---|---|
655
+ | **Dependencies** | Zero runtime (you inject the SDK) | Heavyweight (~50 deps) |
656
+ | **Bundle size** | ~4KB (your code only) | ~100KB+ |
657
+ | **Error messages** | You own them (`errorMapper`) | Amplify's defaults |
658
+ | **Routes** | You own them (`navigate`) | Amplify's hosted UI / config |
659
+ | **Storage** | You choose (`sessionStorage` recommended) | Amplify's `localStorage` default |
660
+ | **Pool config** | String or lazy supplier | Static config at init |
661
+ | **NEW_PASSWORD_REQUIRED** | First-class challenge result | Handled internally |
662
+ | **Product coupling** | None (tested) | Amplify ecosystem assumptions |
663
+ | **TypeScript** | Full types, strict | Full types |
664
+
665
+ **When to use `cognito-client`:** you want a thin, typed, dependency-injected Cognito
666
+ wrapper that you fully control. You own the UX, the error messages, the storage strategy,
667
+ and the routing.
668
+
669
+ **When to use Amplify:** you want a batteries-included auth solution with hosted UI,
670
+ social providers, MFA, and the full Amplify ecosystem — and you're OK with the bundle
671
+ size and opinionated defaults.
672
+
673
+ ---
674
+
675
+ ## Contributing
676
+
677
+ Pull requests are welcome.
678
+
679
+ ### Guidelines
680
+
681
+ 1. Add or update tests for any change (Vitest, jsdom, mock SDK).
682
+ 2. Ensure `pnpm run typecheck` and `pnpm test` pass.
683
+ 3. Do not commit secrets, `.env` files, or `dist/` output.
684
+ 4. Follow the existing code style (strict TypeScript, no `any`, dependency injection).
685
+ 5. **Keep the core product-neutral** — the product-neutrality test must stay green. No
686
+ product roles, routes, or copy in `src/index.ts`.
687
+
688
+ ---
689
+
690
+ ## License
691
+
692
+ [MIT](LICENSE) © Victor Salmon