@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
|
+
}
|