@clocklobster/cognito-client 1.0.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/LICENSE +21 -0
- package/README.md +667 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +275 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Victor Salmon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
# cognito-client
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
[](https://www.typescriptlang.org/)
|
|
5
|
+
[](#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/yarn — the 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
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic browser Cognito lifecycle — product-neutral core.
|
|
3
|
+
*
|
|
4
|
+
* A shared Cognito client contract (sign-up, confirmation, sign-in,
|
|
5
|
+
* restore/refresh, sign-out, reset, token state, and the
|
|
6
|
+
* NEW_PASSWORD_REQUIRED challenge) WITHOUT product roles, routes, visible
|
|
7
|
+
* copy, or default targets.
|
|
8
|
+
*
|
|
9
|
+
* Every dependency is injected:
|
|
10
|
+
* - `sdk` — the amazon-cognito-identity-js namespace (browser global,
|
|
11
|
+
* or a mock in tests).
|
|
12
|
+
* - `userPoolId` / `clientId` — pool configuration; may also be supplied as
|
|
13
|
+
* a lazy function resolved at initPool() time.
|
|
14
|
+
* - `storage` — the Storage used for ALL SDK persistence. Products bind
|
|
15
|
+
* `sessionStorage` here so tokens never touch `localStorage`.
|
|
16
|
+
* - `errorMapper` — maps SDK errors to product copy.
|
|
17
|
+
* - `navigate` / `getCurrentPath` — navigation hooks used by
|
|
18
|
+
* `redirectToLogin`.
|
|
19
|
+
*
|
|
20
|
+
* Invariants:
|
|
21
|
+
* - Runtime tokens live in memory ONLY; the supplied Storage holds the
|
|
22
|
+
* SDK's session (refresh token) so it survives the post-login redirect.
|
|
23
|
+
* - Terminal failure and sign-out clear token/challenge state.
|
|
24
|
+
* - `NEW_PASSWORD_REQUIRED` is surfaced as an authenticated challenge
|
|
25
|
+
* (`SignInResult.challenge`), not an error.
|
|
26
|
+
*/
|
|
27
|
+
/** SignIn result — either tokens (success) or a challenge that must be completed. */
|
|
28
|
+
export type SignInResult = {
|
|
29
|
+
challenge: null;
|
|
30
|
+
idToken: string;
|
|
31
|
+
accessToken: string;
|
|
32
|
+
} | {
|
|
33
|
+
challenge: 'NEW_PASSWORD_REQUIRED';
|
|
34
|
+
userAttributes: Record<string, unknown>;
|
|
35
|
+
requiredAttributes: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
export interface SessionTokens {
|
|
38
|
+
idToken: string;
|
|
39
|
+
accessToken: string;
|
|
40
|
+
}
|
|
41
|
+
export interface RestoredSession extends SessionTokens {
|
|
42
|
+
user: string;
|
|
43
|
+
}
|
|
44
|
+
/** Minimal structural surface of the SDK the core uses. */
|
|
45
|
+
export interface CognitoSdk {
|
|
46
|
+
CognitoUserPool: new (data: {
|
|
47
|
+
UserPoolId: string;
|
|
48
|
+
ClientId: string;
|
|
49
|
+
Storage?: Storage;
|
|
50
|
+
}) => CognitoUserPoolLike;
|
|
51
|
+
CognitoUser: new (data: {
|
|
52
|
+
Username: string;
|
|
53
|
+
Pool: CognitoUserPoolLike;
|
|
54
|
+
Storage?: Storage;
|
|
55
|
+
}) => CognitoUserLike;
|
|
56
|
+
AuthenticationDetails: new (data: {
|
|
57
|
+
Username: string;
|
|
58
|
+
Password: string;
|
|
59
|
+
}) => unknown;
|
|
60
|
+
}
|
|
61
|
+
export interface CognitoUserPoolLike {
|
|
62
|
+
signUp(username: string, password: string, attributeList: Array<{
|
|
63
|
+
Name: string;
|
|
64
|
+
Value: string;
|
|
65
|
+
}>, validationData: unknown[] | null, callback: (err: unknown, result: {
|
|
66
|
+
userConfirmed: boolean;
|
|
67
|
+
userSub: string;
|
|
68
|
+
} | null) => void): void;
|
|
69
|
+
getCurrentUser(): CognitoUserLike | null;
|
|
70
|
+
}
|
|
71
|
+
export interface CognitoUserLike {
|
|
72
|
+
authenticateUser(authenticationDetails: unknown, callbacks: {
|
|
73
|
+
onSuccess: (session: CognitoSessionLike, userConfirmationNecessary?: boolean) => void;
|
|
74
|
+
onFailure: (err: unknown) => void;
|
|
75
|
+
newPasswordRequired?: (userAttributes: Record<string, unknown>, requiredAttributes: Record<string, unknown>) => void;
|
|
76
|
+
}): void;
|
|
77
|
+
getSession(callback: (err: unknown, session: CognitoSessionLike | null) => void): void;
|
|
78
|
+
confirmRegistration(code: string, forceAliasCreation: boolean, callback: (err: unknown, result: unknown) => void): void;
|
|
79
|
+
forgotPassword(callbacks: {
|
|
80
|
+
onSuccess: () => void;
|
|
81
|
+
onFailure: (err: unknown) => void;
|
|
82
|
+
inputVerificationCode: (data?: unknown) => void;
|
|
83
|
+
}): void;
|
|
84
|
+
confirmPassword(code: string, newPassword: string, callbacks: {
|
|
85
|
+
onSuccess: () => void;
|
|
86
|
+
onFailure: (err: unknown) => void;
|
|
87
|
+
}): void;
|
|
88
|
+
signOut(): void;
|
|
89
|
+
getUsername(): string;
|
|
90
|
+
setSignInUserSession(session: CognitoSessionLike): void;
|
|
91
|
+
}
|
|
92
|
+
export interface CognitoSessionLike {
|
|
93
|
+
getIdToken(): {
|
|
94
|
+
getJwtToken(): string;
|
|
95
|
+
};
|
|
96
|
+
getAccessToken(): {
|
|
97
|
+
getJwtToken(): string;
|
|
98
|
+
};
|
|
99
|
+
getRefreshToken(): {
|
|
100
|
+
getToken(): string;
|
|
101
|
+
} | null;
|
|
102
|
+
isValid(): boolean;
|
|
103
|
+
}
|
|
104
|
+
export interface CognitoClientOptions {
|
|
105
|
+
/** Pool configuration — plain string or a lazy supplier resolved at initPool() time. */
|
|
106
|
+
userPoolId: string | (() => string);
|
|
107
|
+
clientId: string | (() => string);
|
|
108
|
+
sdk: CognitoSdk;
|
|
109
|
+
storage: Storage | (() => Storage | undefined);
|
|
110
|
+
errorMapper: (err: unknown) => Error;
|
|
111
|
+
navigate: (url: string) => void;
|
|
112
|
+
/** Current page path + query, used to build the `?returnTo=` destination. */
|
|
113
|
+
getCurrentPath?: () => string;
|
|
114
|
+
}
|
|
115
|
+
export declare class CognitoClient {
|
|
116
|
+
private readonly options;
|
|
117
|
+
private pool;
|
|
118
|
+
private currentUser;
|
|
119
|
+
private idToken;
|
|
120
|
+
private accessToken;
|
|
121
|
+
private pendingChallengeUser;
|
|
122
|
+
constructor(options: CognitoClientOptions);
|
|
123
|
+
private get storage();
|
|
124
|
+
private initPool;
|
|
125
|
+
/** Factory for all CognitoUser instances so they share the pool's session storage. */
|
|
126
|
+
private newCognitoUser;
|
|
127
|
+
signUp(email: string, password: string, attributeList?: Array<{
|
|
128
|
+
Name: string;
|
|
129
|
+
Value: string;
|
|
130
|
+
}>): Promise<{
|
|
131
|
+
userConfirmed: boolean;
|
|
132
|
+
userSub: string;
|
|
133
|
+
}>;
|
|
134
|
+
confirmSignUp(email: string, code: string): Promise<void>;
|
|
135
|
+
signIn(email: string, password: string): Promise<SignInResult>;
|
|
136
|
+
/**
|
|
137
|
+
* Complete a NEW_PASSWORD_REQUIRED challenge issued by signIn().
|
|
138
|
+
*
|
|
139
|
+
* After collecting a new permanent password from the user, the login page calls
|
|
140
|
+
* this with the new password. On success the tokens are stored (same as a
|
|
141
|
+
* normal signIn) and the caller proceeds to its own landing routing.
|
|
142
|
+
*
|
|
143
|
+
* `userAttributes` carries any required attribute updates Cognito requested;
|
|
144
|
+
* the `sub` claim is scrubbed (read-only, Cognito rejects resending it) while
|
|
145
|
+
* anything else the challenge asked for passes through.
|
|
146
|
+
*/
|
|
147
|
+
completeNewPassword(newPassword: string, userAttributes?: Record<string, unknown>): Promise<SessionTokens>;
|
|
148
|
+
getSession(): Promise<RestoredSession | null>;
|
|
149
|
+
refreshSession(): Promise<SessionTokens>;
|
|
150
|
+
forgotPassword(email: string): Promise<void>;
|
|
151
|
+
confirmNewPassword(email: string, code: string, newPassword: string): Promise<void>;
|
|
152
|
+
signOut(): void;
|
|
153
|
+
private clearTokens;
|
|
154
|
+
getUser(): string | null;
|
|
155
|
+
getIdToken(): string | null;
|
|
156
|
+
getAccessToken(): string | null;
|
|
157
|
+
/**
|
|
158
|
+
* Redirect to the login page preserving the current page as `?returnTo=` so
|
|
159
|
+
* the login page can send the user back after a successful sign-in. Signs out
|
|
160
|
+
* any stale Cognito session first (so the login form doesn't pick up a cached
|
|
161
|
+
* user whose tokens are dead). The login URL and return-path validation are
|
|
162
|
+
* product policy — the adapter owns them.
|
|
163
|
+
*/
|
|
164
|
+
redirectToLogin(loginUrl: string): void;
|
|
165
|
+
private setTokensFromSession;
|
|
166
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic browser Cognito lifecycle — product-neutral core.
|
|
3
|
+
*
|
|
4
|
+
* A shared Cognito client contract (sign-up, confirmation, sign-in,
|
|
5
|
+
* restore/refresh, sign-out, reset, token state, and the
|
|
6
|
+
* NEW_PASSWORD_REQUIRED challenge) WITHOUT product roles, routes, visible
|
|
7
|
+
* copy, or default targets.
|
|
8
|
+
*
|
|
9
|
+
* Every dependency is injected:
|
|
10
|
+
* - `sdk` — the amazon-cognito-identity-js namespace (browser global,
|
|
11
|
+
* or a mock in tests).
|
|
12
|
+
* - `userPoolId` / `clientId` — pool configuration; may also be supplied as
|
|
13
|
+
* a lazy function resolved at initPool() time.
|
|
14
|
+
* - `storage` — the Storage used for ALL SDK persistence. Products bind
|
|
15
|
+
* `sessionStorage` here so tokens never touch `localStorage`.
|
|
16
|
+
* - `errorMapper` — maps SDK errors to product copy.
|
|
17
|
+
* - `navigate` / `getCurrentPath` — navigation hooks used by
|
|
18
|
+
* `redirectToLogin`.
|
|
19
|
+
*
|
|
20
|
+
* Invariants:
|
|
21
|
+
* - Runtime tokens live in memory ONLY; the supplied Storage holds the
|
|
22
|
+
* SDK's session (refresh token) so it survives the post-login redirect.
|
|
23
|
+
* - Terminal failure and sign-out clear token/challenge state.
|
|
24
|
+
* - `NEW_PASSWORD_REQUIRED` is surfaced as an authenticated challenge
|
|
25
|
+
* (`SignInResult.challenge`), not an error.
|
|
26
|
+
*/
|
|
27
|
+
/** Resolve a pool-config value, honoring lazy suppliers. */
|
|
28
|
+
function resolvePoolConfig(value) {
|
|
29
|
+
return typeof value === 'function' ? value() : value;
|
|
30
|
+
}
|
|
31
|
+
export class CognitoClient {
|
|
32
|
+
constructor(options) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
this.pool = null;
|
|
35
|
+
this.currentUser = null;
|
|
36
|
+
this.idToken = null;
|
|
37
|
+
this.accessToken = null;
|
|
38
|
+
// Holds the CognitoUser mid-NEW_PASSWORD_REQUIRED challenge, set by signIn()
|
|
39
|
+
// and consumed/cleared by completeNewPassword(). Null when no challenge is in
|
|
40
|
+
// flight (or after signOut / failure).
|
|
41
|
+
this.pendingChallengeUser = null;
|
|
42
|
+
}
|
|
43
|
+
get storage() {
|
|
44
|
+
const s = typeof this.options.storage === 'function' ? this.options.storage() : this.options.storage;
|
|
45
|
+
return s || undefined;
|
|
46
|
+
}
|
|
47
|
+
initPool() {
|
|
48
|
+
if (this.pool)
|
|
49
|
+
return;
|
|
50
|
+
// Resolve lazy pool-config suppliers HERE (first auth operation), not at
|
|
51
|
+
// construction: product adapters may rely on runtime config that is not yet
|
|
52
|
+
// available at import time. By resolving at first use, the pool always sees
|
|
53
|
+
// the current values.
|
|
54
|
+
this.pool = new this.options.sdk.CognitoUserPool({
|
|
55
|
+
UserPoolId: resolvePoolConfig(this.options.userPoolId),
|
|
56
|
+
ClientId: resolvePoolConfig(this.options.clientId),
|
|
57
|
+
...(this.storage ? { Storage: this.storage } : {}),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Factory for all CognitoUser instances so they share the pool's session storage. */
|
|
61
|
+
newCognitoUser(username) {
|
|
62
|
+
this.initPool();
|
|
63
|
+
return new this.options.sdk.CognitoUser({
|
|
64
|
+
Username: username,
|
|
65
|
+
Pool: this.pool,
|
|
66
|
+
...(this.storage ? { Storage: this.storage } : {}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
signUp(email, password, attributeList = []) {
|
|
70
|
+
this.initPool();
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
this.pool.signUp(email, password, attributeList, null, (err, result) => {
|
|
73
|
+
if (err)
|
|
74
|
+
return reject(this.options.errorMapper(err));
|
|
75
|
+
resolve({
|
|
76
|
+
userConfirmed: result.userConfirmed,
|
|
77
|
+
userSub: result.userSub,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
confirmSignUp(email, code) {
|
|
83
|
+
this.initPool();
|
|
84
|
+
const cognitoUser = this.newCognitoUser(email);
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
cognitoUser.confirmRegistration(code, true, (err, _result) => {
|
|
87
|
+
if (err)
|
|
88
|
+
return reject(this.options.errorMapper(err));
|
|
89
|
+
resolve();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
signIn(email, password) {
|
|
94
|
+
this.initPool();
|
|
95
|
+
const authDetails = new this.options.sdk.AuthenticationDetails({
|
|
96
|
+
Username: email,
|
|
97
|
+
Password: password,
|
|
98
|
+
});
|
|
99
|
+
const cognitoUser = this.newCognitoUser(email);
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
cognitoUser.authenticateUser(authDetails, {
|
|
102
|
+
onSuccess: (session) => {
|
|
103
|
+
// Persist the session to the SDK's configured Storage (sessionStorage)
|
|
104
|
+
// so that after the post-login redirect, getSession() can restore it.
|
|
105
|
+
cognitoUser.setSignInUserSession(session);
|
|
106
|
+
this.setTokensFromSession(session, email);
|
|
107
|
+
resolve({ challenge: null, idToken: this.idToken, accessToken: this.accessToken });
|
|
108
|
+
},
|
|
109
|
+
onFailure: (err) => {
|
|
110
|
+
reject(this.options.errorMapper(err));
|
|
111
|
+
},
|
|
112
|
+
newPasswordRequired: (userAttributes, requiredAttributes) => {
|
|
113
|
+
// The user is authenticated but Cognito requires a new permanent password
|
|
114
|
+
// (e.g. admin-created/invited users in FORCE_CHANGE_PASSWORD state). Surface
|
|
115
|
+
// the challenge to the caller; the login page collects a new password and
|
|
116
|
+
// calls completeNewPassword(). Keep the cognitoUser reference alive so the
|
|
117
|
+
// challenge can be completed without re-authenticating.
|
|
118
|
+
this.pendingChallengeUser = cognitoUser;
|
|
119
|
+
resolve({
|
|
120
|
+
challenge: 'NEW_PASSWORD_REQUIRED',
|
|
121
|
+
userAttributes,
|
|
122
|
+
requiredAttributes,
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Complete a NEW_PASSWORD_REQUIRED challenge issued by signIn().
|
|
130
|
+
*
|
|
131
|
+
* After collecting a new permanent password from the user, the login page calls
|
|
132
|
+
* this with the new password. On success the tokens are stored (same as a
|
|
133
|
+
* normal signIn) and the caller proceeds to its own landing routing.
|
|
134
|
+
*
|
|
135
|
+
* `userAttributes` carries any required attribute updates Cognito requested;
|
|
136
|
+
* the `sub` claim is scrubbed (read-only, Cognito rejects resending it) while
|
|
137
|
+
* anything else the challenge asked for passes through.
|
|
138
|
+
*/
|
|
139
|
+
async completeNewPassword(newPassword, userAttributes = {}) {
|
|
140
|
+
if (!this.pendingChallengeUser) {
|
|
141
|
+
throw new Error('No pending password challenge. Please sign in again.');
|
|
142
|
+
}
|
|
143
|
+
const user = this.pendingChallengeUser;
|
|
144
|
+
// Cognito rejects resending the `sub` attribute (it's read-only / server-managed).
|
|
145
|
+
const { sub: _sub, ...safeAttrs } = userAttributes;
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
// `completeNewPasswordChallenge` exists at runtime on CognitoUser but is
|
|
148
|
+
// omitted from the SDK type stubs (the SDK loads as a browser global).
|
|
149
|
+
// Cast to access it.
|
|
150
|
+
user.completeNewPasswordChallenge(newPassword, safeAttrs, {
|
|
151
|
+
onSuccess: (session) => {
|
|
152
|
+
// Persist the completed challenge session so the post-redirect page
|
|
153
|
+
// can restore it via getSession().
|
|
154
|
+
user.setSignInUserSession(session);
|
|
155
|
+
this.setTokensFromSession(session, user.getUsername());
|
|
156
|
+
this.pendingChallengeUser = null;
|
|
157
|
+
resolve({ idToken: this.idToken, accessToken: this.accessToken });
|
|
158
|
+
},
|
|
159
|
+
onFailure: (err) => {
|
|
160
|
+
this.pendingChallengeUser = null;
|
|
161
|
+
reject(this.options.errorMapper(err));
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
getSession() {
|
|
167
|
+
this.initPool();
|
|
168
|
+
const cognitoUser = this.pool.getCurrentUser();
|
|
169
|
+
if (!cognitoUser)
|
|
170
|
+
return Promise.resolve(null);
|
|
171
|
+
return new Promise((resolve) => {
|
|
172
|
+
try {
|
|
173
|
+
cognitoUser.getSession((err, session) => {
|
|
174
|
+
if (err || !session || !session.isValid()) {
|
|
175
|
+
// Terminal failure — a stale/invalid cached session must not
|
|
176
|
+
// leave token state behind.
|
|
177
|
+
cognitoUser.signOut();
|
|
178
|
+
this.clearTokens();
|
|
179
|
+
resolve(null);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
this.setTokensFromSession(session, cognitoUser.getUsername());
|
|
183
|
+
resolve({ idToken: this.idToken, accessToken: this.accessToken, user: this.currentUser });
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// SDK threw synchronously (e.g. no cached refresh token). Treat as unauthenticated.
|
|
188
|
+
this.clearTokens();
|
|
189
|
+
resolve(null);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
refreshSession() {
|
|
194
|
+
this.initPool();
|
|
195
|
+
const cognitoUser = this.pool.getCurrentUser();
|
|
196
|
+
if (!cognitoUser)
|
|
197
|
+
return Promise.reject(new Error('No cached session to refresh'));
|
|
198
|
+
// Load the cached session first. If the id token is still valid, getSession
|
|
199
|
+
// returns it. If it is expired, getSession uses the cached refresh token to
|
|
200
|
+
// fetch a new one. This makes refreshSession safe even when the SDK's
|
|
201
|
+
// signInUserSession has not been loaded into memory yet, e.g. an API call
|
|
202
|
+
// races the page's own getSession() call.
|
|
203
|
+
return new Promise((resolve, reject) => {
|
|
204
|
+
cognitoUser.getSession((err, session) => {
|
|
205
|
+
if (err || !session || !session.isValid()) {
|
|
206
|
+
return reject(err ? this.options.errorMapper(err) : new Error('No valid cached session'));
|
|
207
|
+
}
|
|
208
|
+
this.setTokensFromSession(session, cognitoUser.getUsername());
|
|
209
|
+
resolve({ idToken: this.idToken, accessToken: this.accessToken });
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
forgotPassword(email) {
|
|
214
|
+
this.initPool();
|
|
215
|
+
const cognitoUser = this.newCognitoUser(email);
|
|
216
|
+
return new Promise((resolve, reject) => {
|
|
217
|
+
cognitoUser.forgotPassword({
|
|
218
|
+
onSuccess: () => resolve(),
|
|
219
|
+
onFailure: (err) => reject(this.options.errorMapper(err)),
|
|
220
|
+
inputVerificationCode: () => resolve(),
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
confirmNewPassword(email, code, newPassword) {
|
|
225
|
+
this.initPool();
|
|
226
|
+
const cognitoUser = this.newCognitoUser(email);
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
cognitoUser.confirmPassword(code, newPassword, {
|
|
229
|
+
onSuccess: () => resolve(),
|
|
230
|
+
onFailure: (err) => reject(this.options.errorMapper(err)),
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
signOut() {
|
|
235
|
+
if (this.pool) {
|
|
236
|
+
const cognitoUser = this.pool.getCurrentUser();
|
|
237
|
+
if (cognitoUser)
|
|
238
|
+
cognitoUser.signOut();
|
|
239
|
+
}
|
|
240
|
+
this.clearTokens();
|
|
241
|
+
}
|
|
242
|
+
clearTokens() {
|
|
243
|
+
this.idToken = null;
|
|
244
|
+
this.accessToken = null;
|
|
245
|
+
this.currentUser = null;
|
|
246
|
+
this.pendingChallengeUser = null;
|
|
247
|
+
}
|
|
248
|
+
getUser() {
|
|
249
|
+
return this.currentUser || null;
|
|
250
|
+
}
|
|
251
|
+
getIdToken() {
|
|
252
|
+
return this.idToken || null;
|
|
253
|
+
}
|
|
254
|
+
getAccessToken() {
|
|
255
|
+
return this.accessToken || null;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Redirect to the login page preserving the current page as `?returnTo=` so
|
|
259
|
+
* the login page can send the user back after a successful sign-in. Signs out
|
|
260
|
+
* any stale Cognito session first (so the login form doesn't pick up a cached
|
|
261
|
+
* user whose tokens are dead). The login URL and return-path validation are
|
|
262
|
+
* product policy — the adapter owns them.
|
|
263
|
+
*/
|
|
264
|
+
redirectToLogin(loginUrl) {
|
|
265
|
+
this.signOut();
|
|
266
|
+
const current = this.options.getCurrentPath ? this.options.getCurrentPath() : '';
|
|
267
|
+
const target = current ? `${loginUrl}?returnTo=${encodeURIComponent(current)}` : loginUrl;
|
|
268
|
+
this.options.navigate(target);
|
|
269
|
+
}
|
|
270
|
+
setTokensFromSession(session, username) {
|
|
271
|
+
this.idToken = session.getIdToken().getJwtToken();
|
|
272
|
+
this.accessToken = session.getAccessToken().getJwtToken();
|
|
273
|
+
this.currentUser = username;
|
|
274
|
+
}
|
|
275
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@clocklobster/cognito-client",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"description": "Generic, dependency-injected browser AWS Cognito client — sign-up, sign-in, session restore/refresh, NEW_PASSWORD_REQUIRED challenge, forgot/reset password",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"author": "Victor Salmon",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/victorsalmon/cognito-client.git"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/victorsalmon/cognito-client#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/victorsalmon/cognito-client/issues"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"aws",
|
|
20
|
+
"cognito",
|
|
21
|
+
"authentication",
|
|
22
|
+
"browser",
|
|
23
|
+
"dependency-injection",
|
|
24
|
+
"sign-in",
|
|
25
|
+
"signup",
|
|
26
|
+
"oauth",
|
|
27
|
+
"token",
|
|
28
|
+
"session"
|
|
29
|
+
],
|
|
30
|
+
"type": "module",
|
|
31
|
+
"main": "dist/index.js",
|
|
32
|
+
"types": "dist/index.d.ts",
|
|
33
|
+
"files": [
|
|
34
|
+
"dist"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.build.json",
|
|
38
|
+
"postinstall": "tsc -p tsconfig.build.json",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"test": "vitest run"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"jsdom": "^26.0.0",
|
|
44
|
+
"typescript": "^5.7.2",
|
|
45
|
+
"vitest": "^3.2.7"
|
|
46
|
+
}
|
|
47
|
+
}
|