@oxyhq/core 12.10.5 → 12.11.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +142 -37
- package/dist/cjs/i18n/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/cjs/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/de-DE.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -4
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -4
- package/dist/cjs/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/cjs/i18n/locales/locales/it-IT.json +13 -1
- package/dist/cjs/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/cjs/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/cjs/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/cjs/i18n/locales/pt-PT.json +13 -1
- package/dist/cjs/i18n/locales/zh-CN.json +13 -1
- package/dist/cjs/index.js +31 -5
- package/dist/cjs/mixins/OxyServices.auth.js +257 -17
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +54 -3
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/index.js +5 -0
- package/dist/cjs/session/SessionClient.js +40 -4
- package/dist/cjs/session/accountDialogController.js +288 -41
- package/dist/cjs/session/hubSync.js +1 -1
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +54 -15
- package/dist/cjs/session/refresh.js +73 -8
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/oauthPkce.js +33 -3
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +142 -37
- package/dist/esm/i18n/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/ar-SA.json +13 -1
- package/dist/esm/i18n/locales/locales/ca-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/de-DE.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -4
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -4
- package/dist/esm/i18n/locales/locales/fr-FR.json +13 -1
- package/dist/esm/i18n/locales/locales/it-IT.json +13 -1
- package/dist/esm/i18n/locales/locales/ja-JP.json +13 -1
- package/dist/esm/i18n/locales/locales/ko-KR.json +13 -1
- package/dist/esm/i18n/locales/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/locales/zh-CN.json +13 -1
- package/dist/esm/i18n/locales/pt-PT.json +13 -1
- package/dist/esm/i18n/locales/zh-CN.json +13 -1
- package/dist/esm/index.js +14 -1
- package/dist/esm/mixins/OxyServices.auth.js +257 -17
- package/dist/esm/mixins/OxyServices.deviceBoot.js +52 -3
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/index.js +5 -0
- package/dist/esm/session/SessionClient.js +40 -4
- package/dist/esm/session/accountDialogController.js +288 -41
- package/dist/esm/session/hubSync.js +1 -1
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +54 -15
- package/dist/esm/session/refresh.js +73 -8
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/oauthPkce.js +31 -2
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +38 -1
- package/dist/types/index.d.ts +12 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +262 -11
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +31 -1
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +28 -0
- package/dist/types/session/accountDialogController.d.ts +187 -9
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +21 -19
- package/dist/types/session/refresh.d.ts +32 -1
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/oauthPkce.d.ts +21 -1
- package/package.json +2 -2
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/sessionColdBoot.ts +183 -37
- package/src/i18n/locales/ar-SA.json +13 -1
- package/src/i18n/locales/ca-ES.json +13 -1
- package/src/i18n/locales/de-DE.json +13 -1
- package/src/i18n/locales/en-US.json +13 -4
- package/src/i18n/locales/es-ES.json +13 -4
- package/src/i18n/locales/fr-FR.json +13 -1
- package/src/i18n/locales/it-IT.json +13 -1
- package/src/i18n/locales/ja-JP.json +13 -1
- package/src/i18n/locales/ko-KR.json +13 -1
- package/src/i18n/locales/pt-PT.json +13 -1
- package/src/i18n/locales/zh-CN.json +13 -1
- package/src/index.ts +49 -0
- package/src/mixins/OxyServices.auth.ts +464 -24
- package/src/mixins/OxyServices.deviceBoot.ts +54 -2
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +754 -2
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +18 -0
- package/src/mixins/index.ts +6 -0
- package/src/session/SessionClient.ts +57 -4
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/accountDialogController.test.ts +812 -1
- package/src/session/__tests__/hubSync.test.ts +51 -0
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +88 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +306 -0
- package/src/session/accountDialogController.ts +435 -44
- package/src/session/hubSync.ts +1 -1
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +61 -13
- package/src/session/refresh.ts +103 -9
- package/src/utils/__tests__/oauthPkce.test.ts +51 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/oauthPkce.ts +56 -3
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { syncHubAfterSignIn } from '../hubSync';
|
|
2
|
+
|
|
3
|
+
jest.mock('../../utils/officialOrigins', () => {
|
|
4
|
+
const actual = jest.requireActual('../../utils/officialOrigins');
|
|
5
|
+
return {
|
|
6
|
+
...actual,
|
|
7
|
+
isIdpHubOrigin: jest.fn(() => false),
|
|
8
|
+
isOfficialWebOrigin: jest.fn(() => true),
|
|
9
|
+
buildIdpHubOrigin: jest.fn(() => 'https://auth.oxy.so'),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('syncHubAfterSignIn', () => {
|
|
14
|
+
const originalLocation = globalThis.location;
|
|
15
|
+
const originalAssign = (globalThis as { location?: Location }).location?.assign;
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
Object.defineProperty(globalThis, 'location', {
|
|
19
|
+
configurable: true,
|
|
20
|
+
value: originalLocation,
|
|
21
|
+
});
|
|
22
|
+
jest.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('includes the hash fragment in the hub-sync return URL', async () => {
|
|
26
|
+
const assign = jest.fn();
|
|
27
|
+
Object.defineProperty(globalThis, 'location', {
|
|
28
|
+
configurable: true,
|
|
29
|
+
value: {
|
|
30
|
+
origin: 'https://oxy.so',
|
|
31
|
+
pathname: '/pricing',
|
|
32
|
+
search: '?plan=pro',
|
|
33
|
+
hash: '#compare',
|
|
34
|
+
assign,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
Object.defineProperty(globalThis, 'window', {
|
|
38
|
+
configurable: true,
|
|
39
|
+
value: { location: globalThis.location },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const issueHubTicket = jest.fn().mockResolvedValue({ ticket: 'tk-1' });
|
|
43
|
+
|
|
44
|
+
const redirected = await syncHubAfterSignIn({ issueHubTicket });
|
|
45
|
+
|
|
46
|
+
expect(redirected).toBe(true);
|
|
47
|
+
expect(issueHubTicket).toHaveBeenCalledWith('https://auth.oxy.so');
|
|
48
|
+
const syncUrl = new URL(String(assign.mock.calls[0]?.[0] ?? ''));
|
|
49
|
+
expect(syncUrl.searchParams.get('return')).toBe('https://oxy.so/pricing?plan=pro#compare');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The identity pin store — the durable `{publicKey, accountId}` binding an
|
|
3
|
+
* identity-bound client (the identity vault) uses instead of the device's
|
|
4
|
+
* mutable `activeAccountId`.
|
|
5
|
+
*
|
|
6
|
+
* The load-bearing property is FAIL-CLOSED validation: anything that is not a
|
|
7
|
+
* well-formed pin must read back as "not pinned", because a half-trusted pin
|
|
8
|
+
* would bind the client to the wrong account.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
createMemoryIdentityPinStore,
|
|
12
|
+
createNativeIdentityPinStore,
|
|
13
|
+
identityPinMatches,
|
|
14
|
+
IDENTITY_PIN_STORAGE_KEY,
|
|
15
|
+
type IdentityPin,
|
|
16
|
+
} from '../identityPin';
|
|
17
|
+
import type { NativeKeyValueStorage } from '../authStateStore';
|
|
18
|
+
|
|
19
|
+
/** A syntactically valid compressed secp256k1 public key (66 hex chars). */
|
|
20
|
+
const PUBLIC_KEY = `02${'a'.repeat(64)}`;
|
|
21
|
+
const PIN: IdentityPin = { publicKey: PUBLIC_KEY, accountId: 'acct-1' };
|
|
22
|
+
|
|
23
|
+
/** An in-memory `NativeKeyValueStorage` with the raw map exposed for assertions. */
|
|
24
|
+
function makeNativeStorage(): NativeKeyValueStorage & { raw: Map<string, string> } {
|
|
25
|
+
const raw = new Map<string, string>();
|
|
26
|
+
return {
|
|
27
|
+
raw,
|
|
28
|
+
getItem: async (key) => raw.get(key) ?? null,
|
|
29
|
+
setItem: async (key, value) => {
|
|
30
|
+
raw.set(key, value);
|
|
31
|
+
},
|
|
32
|
+
removeItem: async (key) => {
|
|
33
|
+
raw.delete(key);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('identityPinMatches', () => {
|
|
39
|
+
it('matches case-insensitively (hex)', () => {
|
|
40
|
+
expect(identityPinMatches(PIN, PUBLIC_KEY.toUpperCase())).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('does NOT match a different key', () => {
|
|
44
|
+
expect(identityPinMatches(PIN, `03${'b'.repeat(64)}`)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('does NOT match when the local key is absent (a definitive "identity gone")', () => {
|
|
48
|
+
expect(identityPinMatches(PIN, null)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('does NOT match when there is no pin', () => {
|
|
52
|
+
expect(identityPinMatches(null, PUBLIC_KEY)).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('createMemoryIdentityPinStore', () => {
|
|
57
|
+
it('round-trips a pin and clears it', async () => {
|
|
58
|
+
const store = createMemoryIdentityPinStore();
|
|
59
|
+
expect(await store.load()).toBeNull();
|
|
60
|
+
expect(await store.save(PIN)).toBe(true);
|
|
61
|
+
expect(await store.load()).toEqual(PIN);
|
|
62
|
+
await store.clear();
|
|
63
|
+
expect(await store.load()).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('normalizes the stored public key to lower case', async () => {
|
|
67
|
+
const store = createMemoryIdentityPinStore();
|
|
68
|
+
await store.save({ publicKey: PUBLIC_KEY.toUpperCase(), accountId: 'acct-1' });
|
|
69
|
+
expect((await store.load())?.publicKey).toBe(PUBLIC_KEY);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('refuses to store a malformed pin', async () => {
|
|
73
|
+
const store = createMemoryIdentityPinStore();
|
|
74
|
+
expect(await store.save({ publicKey: 'not-a-key', accountId: 'acct-1' })).toBe(false);
|
|
75
|
+
expect(await store.save({ publicKey: PUBLIC_KEY, accountId: '' })).toBe(false);
|
|
76
|
+
expect(await store.load()).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('createNativeIdentityPinStore', () => {
|
|
81
|
+
it('persists under the versioned key and reads it back', async () => {
|
|
82
|
+
const storage = makeNativeStorage();
|
|
83
|
+
const store = createNativeIdentityPinStore(storage);
|
|
84
|
+
|
|
85
|
+
expect(await store.save(PIN)).toBe(true);
|
|
86
|
+
expect(storage.raw.get(IDENTITY_PIN_STORAGE_KEY)).toBe(
|
|
87
|
+
JSON.stringify({ publicKey: PUBLIC_KEY, accountId: 'acct-1' }),
|
|
88
|
+
);
|
|
89
|
+
// Fresh store instance → reads from storage, not the in-memory mirror.
|
|
90
|
+
expect(await createNativeIdentityPinStore(storage).load()).toEqual(PIN);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it.each([
|
|
94
|
+
['malformed JSON', 'not json'],
|
|
95
|
+
['a non-object', '"a string"'],
|
|
96
|
+
['a junk public key', JSON.stringify({ publicKey: 'zz', accountId: 'acct-1' })],
|
|
97
|
+
['a wrong-length public key', JSON.stringify({ publicKey: 'ab', accountId: 'acct-1' })],
|
|
98
|
+
['an empty accountId', JSON.stringify({ publicKey: PUBLIC_KEY, accountId: '' })],
|
|
99
|
+
['a missing accountId', JSON.stringify({ publicKey: PUBLIC_KEY })],
|
|
100
|
+
])('reads back "not pinned" for %s', async (_label, raw) => {
|
|
101
|
+
const storage = makeNativeStorage();
|
|
102
|
+
storage.raw.set(IDENTITY_PIN_STORAGE_KEY, raw);
|
|
103
|
+
expect(await createNativeIdentityPinStore(storage).load()).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('reports a failed durable write while keeping this run pinned in memory', async () => {
|
|
107
|
+
const storage = makeNativeStorage();
|
|
108
|
+
// A write that resolves WITHOUT throwing yet does not land (the Android
|
|
109
|
+
// SecureStore failure mode) — only the read-back catches it.
|
|
110
|
+
const store = createNativeIdentityPinStore({
|
|
111
|
+
...storage,
|
|
112
|
+
setItem: async () => undefined,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(await store.save(PIN)).toBe(false);
|
|
116
|
+
// The in-memory mirror still pins this app run.
|
|
117
|
+
expect(await store.load()).toEqual(PIN);
|
|
118
|
+
expect(storage.raw.get(IDENTITY_PIN_STORAGE_KEY)).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('degrades to "not pinned" when a read throws', async () => {
|
|
122
|
+
const store = createNativeIdentityPinStore({
|
|
123
|
+
getItem: async () => {
|
|
124
|
+
throw new Error('keychain locked');
|
|
125
|
+
},
|
|
126
|
+
setItem: async () => undefined,
|
|
127
|
+
removeItem: async () => undefined,
|
|
128
|
+
});
|
|
129
|
+
expect(await store.load()).toBeNull();
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity-bound session helpers.
|
|
3
|
+
*
|
|
4
|
+
* `resolveIdentityPin` is the trust gate: a pin is only honoured while the
|
|
5
|
+
* device still holds the key that produced it. `establishIdentitySession` is the
|
|
6
|
+
* primary-key sign-in the identity vault used to run in app code, now owned by
|
|
7
|
+
* the SDK.
|
|
8
|
+
*/
|
|
9
|
+
import type { OxyServices } from '../../OxyServices';
|
|
10
|
+
import type { SessionLoginResponse } from '../../models/session';
|
|
11
|
+
import type { AuthChallenge } from '../../crypto/signatureService';
|
|
12
|
+
import { createMemoryAuthStateStore } from '../authStateStore';
|
|
13
|
+
import { createMemoryIdentityPinStore, type IdentityPin } from '../identityPin';
|
|
14
|
+
import {
|
|
15
|
+
establishIdentitySession,
|
|
16
|
+
resolveIdentityPin,
|
|
17
|
+
type IdentityBinding,
|
|
18
|
+
} from '../identitySession';
|
|
19
|
+
|
|
20
|
+
const PUBLIC_KEY = `02${'a'.repeat(64)}`;
|
|
21
|
+
const OTHER_PUBLIC_KEY = `03${'b'.repeat(64)}`;
|
|
22
|
+
const PIN: IdentityPin = { publicKey: PUBLIC_KEY, accountId: 'acct-1' };
|
|
23
|
+
|
|
24
|
+
const SESSION: SessionLoginResponse = {
|
|
25
|
+
sessionId: 'sess-identity',
|
|
26
|
+
deviceId: 'dev-identity',
|
|
27
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
28
|
+
accessToken: 'access-identity',
|
|
29
|
+
deviceSecret: 'ds-identity',
|
|
30
|
+
user: { id: 'acct-1', username: 'vault', name: {} },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
interface OxyOverrides {
|
|
34
|
+
requestChallenge?: OxyServices['requestChallenge'];
|
|
35
|
+
verifyChallenge?: OxyServices['verifyChallenge'];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeOxy(overrides: OxyOverrides = {}): OxyServices {
|
|
39
|
+
return {
|
|
40
|
+
requestChallenge:
|
|
41
|
+
overrides.requestChallenge
|
|
42
|
+
?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
|
|
43
|
+
verifyChallenge: overrides.verifyChallenge ?? (async () => SESSION),
|
|
44
|
+
} as unknown as OxyServices;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function signWith(publicKey: string): (challenge: string) => Promise<AuthChallenge> {
|
|
48
|
+
return async (challenge) => ({ challenge: `sig(${challenge})`, publicKey, timestamp: 1_700_000_000_000 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('resolveIdentityPin', () => {
|
|
52
|
+
it('returns null when nothing is pinned', async () => {
|
|
53
|
+
const binding: IdentityBinding = {
|
|
54
|
+
pinStore: createMemoryIdentityPinStore(),
|
|
55
|
+
readPublicKey: async () => PUBLIC_KEY,
|
|
56
|
+
};
|
|
57
|
+
expect(await resolveIdentityPin(binding)).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns the pin when the local key still matches', async () => {
|
|
61
|
+
const pinStore = createMemoryIdentityPinStore();
|
|
62
|
+
await pinStore.save(PIN);
|
|
63
|
+
expect(await resolveIdentityPin({ pinStore, readPublicKey: async () => PUBLIC_KEY })).toEqual(PIN);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('CLEARS the pin when the local key was replaced', async () => {
|
|
67
|
+
const pinStore = createMemoryIdentityPinStore();
|
|
68
|
+
await pinStore.save(PIN);
|
|
69
|
+
|
|
70
|
+
expect(await resolveIdentityPin({ pinStore, readPublicKey: async () => OTHER_PUBLIC_KEY })).toBeNull();
|
|
71
|
+
expect(await pinStore.load()).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('CLEARS the pin when the identity is gone (definitive empty read)', async () => {
|
|
75
|
+
const pinStore = createMemoryIdentityPinStore();
|
|
76
|
+
await pinStore.save(PIN);
|
|
77
|
+
|
|
78
|
+
expect(await resolveIdentityPin({ pinStore, readPublicKey: async () => null })).toBeNull();
|
|
79
|
+
expect(await pinStore.load()).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('KEEPS the pin when the key read threw (locked keychain is not an identity change)', async () => {
|
|
83
|
+
const pinStore = createMemoryIdentityPinStore();
|
|
84
|
+
await pinStore.save(PIN);
|
|
85
|
+
|
|
86
|
+
const resolved = await resolveIdentityPin({
|
|
87
|
+
pinStore,
|
|
88
|
+
readPublicKey: async () => {
|
|
89
|
+
throw new Error('IdentityUnavailable');
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Unresolved for this boot...
|
|
94
|
+
expect(resolved).toBeNull();
|
|
95
|
+
// ...but the binding survives for the next attempt.
|
|
96
|
+
expect(await pinStore.load()).toEqual(PIN);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('establishIdentitySession', () => {
|
|
101
|
+
it('signs with the PRIMARY key, persists the device credential, and writes the pin', async () => {
|
|
102
|
+
const store = createMemoryAuthStateStore();
|
|
103
|
+
const pinStore = createMemoryIdentityPinStore();
|
|
104
|
+
const requestChallenge = jest.fn(async () => ({
|
|
105
|
+
challenge: 'chal-1',
|
|
106
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
107
|
+
}));
|
|
108
|
+
const verifyChallenge = jest.fn(async () => SESSION);
|
|
109
|
+
const oxy = makeOxy({ requestChallenge, verifyChallenge });
|
|
110
|
+
|
|
111
|
+
const established = await establishIdentitySession({
|
|
112
|
+
oxy,
|
|
113
|
+
store,
|
|
114
|
+
binding: {
|
|
115
|
+
pinStore,
|
|
116
|
+
readPublicKey: async () => PUBLIC_KEY,
|
|
117
|
+
signChallenge: signWith(PUBLIC_KEY),
|
|
118
|
+
deviceName: 'Vault',
|
|
119
|
+
},
|
|
120
|
+
requestOptions: { retry: false },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(requestChallenge).toHaveBeenCalledWith(PUBLIC_KEY, { retry: false });
|
|
124
|
+
expect(verifyChallenge).toHaveBeenCalledWith(
|
|
125
|
+
PUBLIC_KEY,
|
|
126
|
+
'chal-1',
|
|
127
|
+
'sig(chal-1)',
|
|
128
|
+
1_700_000_000_000,
|
|
129
|
+
'Vault',
|
|
130
|
+
undefined,
|
|
131
|
+
{ retry: false },
|
|
132
|
+
);
|
|
133
|
+
expect(established?.pin).toEqual(PIN);
|
|
134
|
+
expect(await pinStore.load()).toEqual(PIN);
|
|
135
|
+
expect(await store.load()).toMatchObject({
|
|
136
|
+
sessionId: 'sess-identity',
|
|
137
|
+
userId: 'acct-1',
|
|
138
|
+
deviceId: 'dev-identity',
|
|
139
|
+
deviceSecret: 'ds-identity',
|
|
140
|
+
accessToken: 'access-identity',
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns null (never throws) when the device holds no identity — the web case', async () => {
|
|
145
|
+
const pinStore = createMemoryIdentityPinStore();
|
|
146
|
+
const requestChallenge = jest.fn(async () => ({ challenge: 'c', expiresAt: 'e' }));
|
|
147
|
+
|
|
148
|
+
const established = await establishIdentitySession({
|
|
149
|
+
oxy: makeOxy({ requestChallenge }),
|
|
150
|
+
store: createMemoryAuthStateStore(),
|
|
151
|
+
binding: { pinStore, readPublicKey: async () => null },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(established).toBeNull();
|
|
155
|
+
expect(requestChallenge).not.toHaveBeenCalled();
|
|
156
|
+
expect(await pinStore.load()).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('refuses to verify when the signer disagrees with the device identity key', async () => {
|
|
160
|
+
const pinStore = createMemoryIdentityPinStore();
|
|
161
|
+
const verifyChallenge = jest.fn(async () => SESSION);
|
|
162
|
+
|
|
163
|
+
await expect(
|
|
164
|
+
establishIdentitySession({
|
|
165
|
+
oxy: makeOxy({ verifyChallenge }),
|
|
166
|
+
store: createMemoryAuthStateStore(),
|
|
167
|
+
binding: {
|
|
168
|
+
pinStore,
|
|
169
|
+
readPublicKey: async () => PUBLIC_KEY,
|
|
170
|
+
signChallenge: signWith(OTHER_PUBLIC_KEY),
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
).rejects.toThrow(/signing key does not match/);
|
|
174
|
+
|
|
175
|
+
expect(verifyChallenge).not.toHaveBeenCalled();
|
|
176
|
+
expect(await pinStore.load()).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('keeps the established session when the pin write fails (re-established next boot)', async () => {
|
|
180
|
+
const pinStore = {
|
|
181
|
+
load: async () => null,
|
|
182
|
+
save: async () => false,
|
|
183
|
+
clear: async () => undefined,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const established = await establishIdentitySession({
|
|
187
|
+
oxy: makeOxy(),
|
|
188
|
+
store: createMemoryAuthStateStore(),
|
|
189
|
+
binding: { pinStore, readPublicKey: async () => PUBLIC_KEY, signChallenge: signWith(PUBLIC_KEY) },
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(established?.session.accessToken).toBe('access-identity');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `DeviceSessionState` projections, with and without an identity pin.
|
|
3
|
+
*
|
|
4
|
+
* Without a pin the projections resolve the device's `activeAccountId` exactly
|
|
5
|
+
* as they always have. With one they resolve the PINNED account, so an account
|
|
6
|
+
* switch made by another app on the same device changes `state` but never the
|
|
7
|
+
* user an identity-bound client renders.
|
|
8
|
+
*/
|
|
9
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
10
|
+
import type { User } from '../../models/interfaces';
|
|
11
|
+
import {
|
|
12
|
+
accountIdsOf,
|
|
13
|
+
activeSessionIdOf,
|
|
14
|
+
activeUserOf,
|
|
15
|
+
deviceStateToClientSessions,
|
|
16
|
+
} from '../projectSessionState';
|
|
17
|
+
|
|
18
|
+
const PINNED = 'vault-user';
|
|
19
|
+
const OTHER = 'other-user';
|
|
20
|
+
|
|
21
|
+
const STATE: DeviceSessionState = {
|
|
22
|
+
deviceId: 'dev-1',
|
|
23
|
+
accounts: [
|
|
24
|
+
{ accountId: PINNED, sessionId: 'sess-vault', authuser: 0 },
|
|
25
|
+
{ accountId: OTHER, sessionId: 'sess-other', authuser: 1 },
|
|
26
|
+
],
|
|
27
|
+
// Another app switched the device away from the pinned account.
|
|
28
|
+
activeAccountId: OTHER,
|
|
29
|
+
revision: 4,
|
|
30
|
+
updatedAt: 1_720_000_000_000,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const user = (id: string): User => ({ id, username: id, name: {} });
|
|
34
|
+
const USERS = new Map<string, User>([
|
|
35
|
+
[PINNED, user(PINNED)],
|
|
36
|
+
[OTHER, user(OTHER)],
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
describe('projections — unpinned (unchanged behaviour)', () => {
|
|
40
|
+
it('resolves the device active account', () => {
|
|
41
|
+
expect(activeSessionIdOf(STATE)).toBe('sess-other');
|
|
42
|
+
expect(activeUserOf(STATE, USERS)?.id).toBe(OTHER);
|
|
43
|
+
expect(deviceStateToClientSessions(STATE, USERS).map((s) => s.isCurrent)).toEqual([false, true]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns null for a null state or no active account', () => {
|
|
47
|
+
expect(activeSessionIdOf(null)).toBeNull();
|
|
48
|
+
expect(activeUserOf(null, USERS)).toBeNull();
|
|
49
|
+
expect(activeSessionIdOf({ ...STATE, activeAccountId: null })).toBeNull();
|
|
50
|
+
expect(activeUserOf({ ...STATE, activeAccountId: null }, USERS)).toBeNull();
|
|
51
|
+
expect(accountIdsOf(null)).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('treats an empty-string pin as "not pinned"', () => {
|
|
55
|
+
expect(activeSessionIdOf(STATE, '')).toBe('sess-other');
|
|
56
|
+
expect(activeUserOf(STATE, USERS, '')?.id).toBe(OTHER);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('projections — pinned', () => {
|
|
61
|
+
it('resolves the PINNED account, not the device active one', () => {
|
|
62
|
+
expect(activeSessionIdOf(STATE, PINNED)).toBe('sess-vault');
|
|
63
|
+
expect(activeUserOf(STATE, USERS, PINNED)?.id).toBe(PINNED);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('marks the pinned account as `isCurrent` so the session list cannot disagree', () => {
|
|
67
|
+
expect(deviceStateToClientSessions(STATE, USERS, PINNED).map((s) => s.isCurrent)).toEqual([
|
|
68
|
+
true,
|
|
69
|
+
false,
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('reports NO session id when the pinned account is absent from the device', () => {
|
|
74
|
+
const withoutPinned: DeviceSessionState = {
|
|
75
|
+
...STATE,
|
|
76
|
+
accounts: [{ accountId: OTHER, sessionId: 'sess-other', authuser: 0 }],
|
|
77
|
+
};
|
|
78
|
+
// The honest signal the caller answers by re-establishing the identity
|
|
79
|
+
// session — never by silently adopting `other-user`.
|
|
80
|
+
expect(activeSessionIdOf(withoutPinned, PINNED)).toBeNull();
|
|
81
|
+
// The rendered user does not flicker to the other account while that happens.
|
|
82
|
+
expect(activeUserOf(withoutPinned, USERS, PINNED)?.id).toBe(PINNED);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('still enumerates every account id (profile hydration is unaffected)', () => {
|
|
86
|
+
expect(accountIdsOf(STATE)).toEqual([PINNED, OTHER]);
|
|
87
|
+
});
|
|
88
|
+
});
|