@biffo/cli 0.168.2 → 0.168.4

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.
@@ -4,6 +4,7 @@ import {
4
4
  type ICognitoUserPoolData,
5
5
  } from 'amazon-cognito-identity-js'
6
6
 
7
+ import { pruneForeignCognitoCredentials } from './cognito-hygiene'
7
8
  import { resolveCoreIdentity } from './identity'
8
9
 
9
10
  // ---------------------------------------------------------------------------
@@ -60,6 +61,12 @@ async function getUserPool(): Promise<CognitoUserPool | null> {
60
61
  const identity = await resolveCoreIdentity()
61
62
  if (!identity) return null
62
63
 
64
+ // Once per page load, and only with a resolved client id: drop credentials
65
+ // left behind by pools this deployment no longer uses (biffo-template#834).
66
+ // Cheap, and it keeps the shared origin from accumulating dead tokens for
67
+ // every pool the portal has ever pointed at.
68
+ pruneForeignCognitoCredentials(identity.clientId)
69
+
63
70
  const poolData: ICognitoUserPoolData = {
64
71
  UserPoolId: identity.userPoolId,
65
72
  ClientId: identity.clientId,
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { pruneForeignCognitoCredentials } from './cognito-hygiene'
4
+
5
+ /**
6
+ * A minimal Storage over a Map — enough for the length/key/removeItem trio the
7
+ * implementation uses. Backed by a Map rather than a plain object so the fake
8
+ * needs no dynamic `delete` (banned by @typescript-eslint/no-dynamic-delete,
9
+ * which `next build`'s lint enforces and a bare `pnpm run lint` does not).
10
+ */
11
+ function fakeStorage(entries: Record<string, string>): Storage {
12
+ const map = new Map(Object.entries(entries))
13
+ return {
14
+ get length() {
15
+ return map.size
16
+ },
17
+ clear: () => {
18
+ map.clear()
19
+ },
20
+ getItem: (k: string) => map.get(k) ?? null,
21
+ key: (i: number) => [...map.keys()][i] ?? null,
22
+ removeItem: (k: string) => {
23
+ map.delete(k)
24
+ },
25
+ setItem: (k: string, v: string) => {
26
+ map.set(k, v)
27
+ },
28
+ }
29
+ }
30
+
31
+ const LIVE = '1ccelkl84t2o4euir3op3nco5j'
32
+
33
+ describe('pruneForeignCognitoCredentials', () => {
34
+ it('removes credentials belonging to other client ids', () => {
35
+ const storage = fakeStorage({
36
+ [`CognitoIdentityServiceProvider.${LIVE}.someone.idToken`]: 'keep',
37
+ [`CognitoIdentityServiceProvider.${LIVE}.LastAuthUser`]: 'keep',
38
+ 'CognitoIdentityServiceProvider.5vjn6648jtcsguaodtn786lsi6.someone.idToken': 'dead',
39
+ 'CognitoIdentityServiceProvider.5vjn6648jtcsguaodtn786lsi6.LastAuthUser': 'dead',
40
+ 'CognitoIdentityServiceProvider.2tvepqhn2j4aje4uqvu9fiu1ge.someone.accessToken': 'dead',
41
+ })
42
+
43
+ const removed = pruneForeignCognitoCredentials(LIVE, storage)
44
+
45
+ expect(removed).toHaveLength(3)
46
+ expect(storage.length).toBe(2)
47
+ expect(storage.getItem(`CognitoIdentityServiceProvider.${LIVE}.someone.idToken`)).toBe('keep')
48
+ expect(storage.getItem(`CognitoIdentityServiceProvider.${LIVE}.LastAuthUser`)).toBe('keep')
49
+ expect(
50
+ storage.getItem('CognitoIdentityServiceProvider.5vjn6648jtcsguaodtn786lsi6.LastAuthUser'),
51
+ ).toBeNull()
52
+ })
53
+
54
+ it('leaves unrelated keys alone', () => {
55
+ const storage = fakeStorage({
56
+ theme: 'dark',
57
+ 'amplify-signin-with-hostedUI': 'false',
58
+ [`CognitoIdentityServiceProvider.${LIVE}.LastAuthUser`]: 'keep',
59
+ 'CognitoIdentityServiceProvider.dead.LastAuthUser': 'dead',
60
+ })
61
+
62
+ pruneForeignCognitoCredentials(LIVE, storage)
63
+
64
+ expect(storage.getItem('theme')).toBe('dark')
65
+ expect(storage.getItem('amplify-signin-with-hostedUI')).toBe('false')
66
+ })
67
+
68
+ it('does nothing when the client id is unresolved', () => {
69
+ // The dangerous case: treating "no identity" as "nothing matches" would
70
+ // delete the live session along with the residue.
71
+ const storage = fakeStorage({
72
+ [`CognitoIdentityServiceProvider.${LIVE}.LastAuthUser`]: 'keep',
73
+ 'CognitoIdentityServiceProvider.dead.LastAuthUser': 'dead',
74
+ })
75
+
76
+ expect(pruneForeignCognitoCredentials(null, storage)).toEqual([])
77
+ expect(pruneForeignCognitoCredentials('', storage)).toEqual([])
78
+ expect(storage.getItem(`CognitoIdentityServiceProvider.${LIVE}.LastAuthUser`)).toBe('keep')
79
+ expect(storage.getItem('CognitoIdentityServiceProvider.dead.LastAuthUser')).toBe('dead')
80
+ })
81
+
82
+ it('is a no-op without storage, so a Node prerender cannot crash on it', () => {
83
+ expect(pruneForeignCognitoCredentials(LIVE, undefined)).toEqual([])
84
+ })
85
+
86
+ it('removes every key of a stale client, not just the first', () => {
87
+ // removeItem() mutates the live key set; index-based iteration would skip
88
+ // entries as it shrinks and leave half the residue behind.
89
+ const entries: Record<string, string> = {
90
+ [`CognitoIdentityServiceProvider.${LIVE}.LastAuthUser`]: 'keep',
91
+ }
92
+ for (let i = 0; i < 12; i++) entries[`CognitoIdentityServiceProvider.dead.k${String(i)}`] = 'x'
93
+ const storage = fakeStorage(entries)
94
+
95
+ expect(pruneForeignCognitoCredentials(LIVE, storage)).toHaveLength(12)
96
+ expect(storage.getItem('CognitoIdentityServiceProvider.dead.k11')).toBeNull()
97
+ })
98
+ })
@@ -0,0 +1,60 @@
1
+ // Removing Cognito credentials belonging to pools this deployment no longer uses.
2
+ //
3
+ // `amazon-cognito-identity-js` stores tokens under keys shaped
4
+ // `CognitoIdentityServiceProvider.<ClientId>.<username>.<tokenType>` and reads
5
+ // them back scoped by Client ID. Replacing a user pool therefore does not clear
6
+ // the old one's keys: they are simply never read again, and they accumulate for
7
+ // as long as the browser profile lives. `dev.biffo.io` was measured carrying
8
+ // four pools' credentials — three of them dead — where AWS has one pool and one
9
+ // client (biffo-template#834).
10
+ //
11
+ // What this is NOT: a fix for a wrong-identity read. Because every consumer
12
+ // resolves its pool from the runtime identity document (#403) and lets
13
+ // amazon-cognito-identity-js scope the lookup by Client ID, a stale pool's
14
+ // tokens are never enumerated and never selected. That claim was made and
15
+ // withdrawn on #834.
16
+ //
17
+ // What it IS: dead bearer tokens for real identities should not sit in browser
18
+ // storage forever, and any future code that enumerates
19
+ // `CognitoIdentityServiceProvider.*` — a debug helper, a sign-out-everywhere
20
+ // action, a migration — should not inherit a growing minefield.
21
+
22
+ /** `CognitoIdentityServiceProvider.<clientId>.<rest…>` — capture the client id. */
23
+ const COGNITO_KEY = /^CognitoIdentityServiceProvider\.([^.]+)\./
24
+
25
+ /**
26
+ * Delete every Cognito credential key whose Client ID is not `clientId`.
27
+ *
28
+ * Returns the keys removed, so a caller can log or assert on them. A no-op when
29
+ * `clientId` is falsy — an unresolved identity must never be treated as "no
30
+ * client matches", which would delete the live session along with the residue.
31
+ *
32
+ * Storage is injected for tests and to stay safe where there is none: this
33
+ * module is imported during `next build`'s prerender in Node, where
34
+ * `localStorage` does not exist.
35
+ */
36
+ export function pruneForeignCognitoCredentials(
37
+ clientId: string | null | undefined,
38
+ storage: Storage | undefined = typeof localStorage === 'undefined' ? undefined : localStorage,
39
+ ): string[] {
40
+ if (!clientId || !storage) return []
41
+
42
+ // Snapshot the keys first, via the Storage API rather than Object.keys:
43
+ // removeItem() mutates the live key set, so index-based iteration would skip
44
+ // entries as it shrinks and leave half the residue behind.
45
+ const keys: string[] = []
46
+ for (let i = 0; i < storage.length; i++) {
47
+ const key = storage.key(i)
48
+ if (key !== null) keys.push(key)
49
+ }
50
+
51
+ const removed: string[] = []
52
+ for (const key of keys) {
53
+ const match = COGNITO_KEY.exec(key)
54
+ if (match && match[1] !== clientId) {
55
+ storage.removeItem(key)
56
+ removed.push(key)
57
+ }
58
+ }
59
+ return removed
60
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.168.2",
3
+ "version": "0.168.4",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",