@oxyhq/core 20.1.0 → 21.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/sessionColdBoot.js +107 -8
- package/dist/cjs/i18n/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/es-ES.json +19 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +19 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +19 -2
- package/dist/cjs/index.js +50 -16
- package/dist/cjs/mixins/OxyServices.auth.js +27 -3
- package/dist/cjs/session/SessionClient.js +361 -1
- package/dist/cjs/session/accountDialogController.js +121 -147
- package/dist/cjs/session/accountSwitchTargets.js +75 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/projectSessionState.js +8 -1
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +107 -8
- package/dist/esm/i18n/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/es-ES.json +19 -2
- package/dist/esm/i18n/locales/locales/en-US.json +19 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +19 -2
- package/dist/esm/index.js +32 -10
- package/dist/esm/mixins/OxyServices.auth.js +27 -3
- package/dist/esm/session/SessionClient.js +362 -2
- package/dist/esm/session/accountDialogController.js +121 -147
- package/dist/esm/session/accountSwitchTargets.js +71 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/projectSessionState.js +8 -2
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +24 -4
- package/dist/types/index.d.ts +8 -3
- package/dist/types/mixins/OxyServices.auth.d.ts +75 -3
- package/dist/types/models/session.d.ts +11 -0
- package/dist/types/session/SessionClient.d.ts +202 -1
- package/dist/types/session/accountDialogController.d.ts +76 -64
- package/dist/types/session/accountSwitchTargets.d.ts +64 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/projectSessionState.d.ts +29 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/package.json +3 -3
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/sessionColdBoot.ts +133 -9
- package/src/i18n/locales/en-US.json +19 -2
- package/src/i18n/locales/es-ES.json +19 -2
- package/src/index.ts +75 -18
- package/src/mixins/OxyServices.auth.ts +67 -5
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +54 -1
- package/src/models/session.ts +11 -0
- package/src/session/SessionClient.ts +386 -1
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/accountDialogController.test.ts +411 -278
- package/src/session/__tests__/accountSwitchTargets.test.ts +132 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/projectSessionState.test.ts +17 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDialogController.ts +141 -179
- package/src/session/accountSwitchTargets.ts +87 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/projectSessionState.ts +9 -3
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/dist/cjs/session/accountProjection.js +0 -213
- package/dist/esm/session/accountProjection.js +0 -207
- package/dist/types/session/accountProjection.d.ts +0 -198
- package/src/session/__tests__/accountProjection.test.ts +0 -447
- package/src/session/accountProjection.ts +0 -354
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared native DeviceSession credential — one device, one session, many apps.
|
|
3
|
+
*
|
|
4
|
+
* ## What this is, and what it deliberately is NOT
|
|
5
|
+
*
|
|
6
|
+
* Two different secrets can make a native Oxy app boot signed in, and conflating
|
|
7
|
+
* them is the bug this module exists to end:
|
|
8
|
+
*
|
|
9
|
+
* Commons private identity key → identity and signed approval ONLY. It is
|
|
10
|
+
* self-custody, irreplaceable, and must never
|
|
11
|
+
* become the general app session transport.
|
|
12
|
+
* Shared DeviceSession credential → THIS module. An ordinary `deviceId` +
|
|
13
|
+
* `deviceSecret` pair that restores ordinary
|
|
14
|
+
* official apps, follows the device's active
|
|
15
|
+
* context, and is individually rotatable and
|
|
16
|
+
* revocable server-side.
|
|
17
|
+
*
|
|
18
|
+
* An ordinary app needs the second one. Handing it the first — which is what the
|
|
19
|
+
* `shared-key-signin` lane does today — gives every sibling app the ability to
|
|
20
|
+
* sign as the user's cryptographic identity to obtain something as mundane as a
|
|
21
|
+
* session. That lane stays as a recovery/compatibility path; this one supersedes
|
|
22
|
+
* it for the ordinary case.
|
|
23
|
+
*
|
|
24
|
+
* ## Why sharing one credential is safe against the server
|
|
25
|
+
*
|
|
26
|
+
* `POST /session/device/token` does NOT rotate: the response echoes the presented
|
|
27
|
+
* secret back as `nextDeviceSecret` precisely so several first-party apps sharing
|
|
28
|
+
* one `DeviceSession` can refresh concurrently without invalidating one another.
|
|
29
|
+
* So N apps holding one credential is a supported server state, not a race — and
|
|
30
|
+
* because they then share ONE `DeviceSession`, they automatically share its
|
|
31
|
+
* `activeContextId` and its token-free `session_state` broadcasts.
|
|
32
|
+
*
|
|
33
|
+
* ## The one safety rule everything here is built around
|
|
34
|
+
*
|
|
35
|
+
* A read that FAILED and a slot that is EMPTY must never be the same value. The
|
|
36
|
+
* empty answer authorises writes (seed the slot); the failed answer must
|
|
37
|
+
* authorise nothing. {@link SharedDeviceCredentialRead} keeps them apart at the
|
|
38
|
+
* type level, and every decision below fails closed on anything that is not a
|
|
39
|
+
* positive `absent`/`present`.
|
|
40
|
+
*
|
|
41
|
+
* Platform-agnostic: the actual keychain / keystore access is injected as a
|
|
42
|
+
* {@link SharedDeviceCredentialStore} by `@oxyhq/services`. ESM-safe, no
|
|
43
|
+
* `require()`, no react/react-native/expo imports.
|
|
44
|
+
*/
|
|
45
|
+
import type { AuthStateStore, PersistedAuthState } from './authStateStore';
|
|
46
|
+
/**
|
|
47
|
+
* The zero-cookie device credential, as shared between apps. Exactly the pair
|
|
48
|
+
* `POST /session/device/token` takes — nothing else travels through the shared
|
|
49
|
+
* slot: no access token, no account id, no user id, no identity key.
|
|
50
|
+
*/
|
|
51
|
+
export interface SharedDeviceCredential {
|
|
52
|
+
deviceId: string;
|
|
53
|
+
deviceSecret: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The outcome of reading the shared slot. FOUR states, and the distinction
|
|
57
|
+
* between the last two is load-bearing:
|
|
58
|
+
*
|
|
59
|
+
* - `present` — a well-formed credential was read.
|
|
60
|
+
* - `absent` — the read SUCCEEDED and the slot is empty. The only state that
|
|
61
|
+
* may authorise seeding the slot.
|
|
62
|
+
* - `unavailable` — the read failed (keychain locked, keystore unreadable, the
|
|
63
|
+
* bridge returned something unrecognisable). Authorises
|
|
64
|
+
* nothing: not adoption, and above all not a write.
|
|
65
|
+
* - `unsupported` — this build has no shared slot at all (web, or a native app
|
|
66
|
+
* without the module linked). Not an error; simply means the
|
|
67
|
+
* app keeps its own per-app credential.
|
|
68
|
+
*/
|
|
69
|
+
export type SharedDeviceCredentialRead = {
|
|
70
|
+
state: 'present';
|
|
71
|
+
credential: SharedDeviceCredential;
|
|
72
|
+
} | {
|
|
73
|
+
state: 'absent';
|
|
74
|
+
} | {
|
|
75
|
+
state: 'unavailable';
|
|
76
|
+
cause: unknown;
|
|
77
|
+
} | {
|
|
78
|
+
state: 'unsupported';
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The platform seam. `@oxyhq/services` implements this over the iOS Keychain
|
|
82
|
+
* Access Group (a dedicated `keychainService`) or the Android signature-protected
|
|
83
|
+
* `OxyDeviceSession` broker.
|
|
84
|
+
*/
|
|
85
|
+
export interface SharedDeviceCredentialStore {
|
|
86
|
+
/** Never throws — a failure is reported as `unavailable`, never as `absent`. */
|
|
87
|
+
read(): Promise<SharedDeviceCredentialRead>;
|
|
88
|
+
/**
|
|
89
|
+
* Publish the credential. Resolves `true` only when a read-back confirmed the
|
|
90
|
+
* exact bytes landed; `false` on any failure. Never throws.
|
|
91
|
+
*/
|
|
92
|
+
publish(credential: SharedDeviceCredential): Promise<boolean>;
|
|
93
|
+
/** Drop this app's copy of the shared credential. Never throws. */
|
|
94
|
+
clear(): Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
/** Why {@link decideSharedDeviceJoin} declined to adopt the shared credential. */
|
|
97
|
+
export type SharedDeviceJoinSkipReason = 'shared-unsupported' | 'shared-unreadable' | 'shared-empty' | 'local-credential-present';
|
|
98
|
+
/** What a booting app should do with the shared slot it just read. */
|
|
99
|
+
export type SharedDeviceJoinDecision = {
|
|
100
|
+
action: 'adopt';
|
|
101
|
+
credential: SharedDeviceCredential;
|
|
102
|
+
} | {
|
|
103
|
+
action: 'skip';
|
|
104
|
+
reason: SharedDeviceJoinSkipReason;
|
|
105
|
+
};
|
|
106
|
+
/** Why {@link decideSharedDevicePublish} declined to write the shared slot. */
|
|
107
|
+
export type SharedDevicePublishSkipReason = 'shared-unsupported' | 'shared-unreadable' | 'already-current' | 'owned-by-another-device';
|
|
108
|
+
/** What an app that just PROVED a credential should do with the shared slot. */
|
|
109
|
+
export type SharedDevicePublishDecision = {
|
|
110
|
+
action: 'publish';
|
|
111
|
+
} | {
|
|
112
|
+
action: 'skip';
|
|
113
|
+
reason: SharedDevicePublishSkipReason;
|
|
114
|
+
};
|
|
115
|
+
/** The usable `{deviceId, deviceSecret}` pair in a persisted state, or null. */
|
|
116
|
+
export declare function readLocalDeviceCredential(state: PersistedAuthState | null): SharedDeviceCredential | null;
|
|
117
|
+
/**
|
|
118
|
+
* Narrow an UNTRUSTED bridge payload into a {@link SharedDeviceCredentialRead}.
|
|
119
|
+
*
|
|
120
|
+
* Anything unrecognised resolves to `unavailable`, never `absent`. A native
|
|
121
|
+
* module returning a shape this build does not understand (an older app in the
|
|
122
|
+
* signing group, a partially-applied upgrade) means we do not KNOW whether the
|
|
123
|
+
* device has a shared session — and "do not know" must never authorise a write
|
|
124
|
+
* that would overwrite one.
|
|
125
|
+
*/
|
|
126
|
+
export declare function normalizeSharedDeviceSessionRead(raw: unknown): SharedDeviceCredentialRead;
|
|
127
|
+
/**
|
|
128
|
+
* Should this app adopt the shared credential? Pure.
|
|
129
|
+
*
|
|
130
|
+
* Adoption happens in exactly ONE case: the shared slot holds a credential and
|
|
131
|
+
* this app has none of its own. That single rule delivers the product
|
|
132
|
+
* requirement — a newly installed official app joins the device's existing
|
|
133
|
+
* session without another QR — while making the two failure modes that matter
|
|
134
|
+
* unreachable:
|
|
135
|
+
*
|
|
136
|
+
* - An app that is already signed in is NEVER moved onto another credential, so
|
|
137
|
+
* "no user is signed out merely because one app updates first" holds by
|
|
138
|
+
* construction, in both upgrade directions.
|
|
139
|
+
* - A failed read never looks like an empty slot, so a locked keychain resolves
|
|
140
|
+
* to "keep what I have" rather than "this is a fresh device".
|
|
141
|
+
*
|
|
142
|
+
* The cost is stated plainly: two apps that each already own a DIFFERENT device
|
|
143
|
+
* session stay on their own until one of them loses its credential. Converging
|
|
144
|
+
* them would mean signing one of them out or a server-side device merge, and
|
|
145
|
+
* neither is something a boot path may do silently.
|
|
146
|
+
*/
|
|
147
|
+
export declare function decideSharedDeviceJoin(local: PersistedAuthState | null, shared: SharedDeviceCredentialRead): SharedDeviceJoinDecision;
|
|
148
|
+
/**
|
|
149
|
+
* Should this app write the credential it just proved into the shared slot? Pure.
|
|
150
|
+
*
|
|
151
|
+
* `proven` means the server accepted it moments ago — a successful sign-in or a
|
|
152
|
+
* successful mint. Only a proven credential is ever published, so the slot can
|
|
153
|
+
* never be seeded with something no app could use.
|
|
154
|
+
*
|
|
155
|
+
* A slot already held by a DIFFERENT `deviceId` is left alone. Overwriting it
|
|
156
|
+
* would silently migrate every other app on this device onto our session at their
|
|
157
|
+
* next cold boot — a real, user-visible change of who they are signed in as, and
|
|
158
|
+
* not something a background persist may decide.
|
|
159
|
+
*/
|
|
160
|
+
export declare function decideSharedDevicePublish(proven: SharedDeviceCredential, shared: SharedDeviceCredentialRead): SharedDevicePublishDecision;
|
|
161
|
+
/** The result of {@link publishProvenDeviceCredential}, for logs and tests. */
|
|
162
|
+
export type SharedDevicePublishOutcome = {
|
|
163
|
+
status: 'published';
|
|
164
|
+
} | {
|
|
165
|
+
status: 'publish-failed';
|
|
166
|
+
} | {
|
|
167
|
+
status: 'skipped';
|
|
168
|
+
reason: SharedDevicePublishSkipReason;
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Read the shared slot, apply {@link decideSharedDevicePublish}, and write when
|
|
172
|
+
* it says so. Best-effort by contract: the caller's own durable credential is
|
|
173
|
+
* already persisted, so a failure here only means a future install will have to
|
|
174
|
+
* sign in interactively.
|
|
175
|
+
*/
|
|
176
|
+
export declare function publishProvenDeviceCredential(deps: {
|
|
177
|
+
shared: SharedDeviceCredentialStore;
|
|
178
|
+
credential: SharedDeviceCredential;
|
|
179
|
+
}): Promise<SharedDevicePublishOutcome>;
|
|
180
|
+
/**
|
|
181
|
+
* Wrap a platform {@link AuthStateStore} so that every durable credential it
|
|
182
|
+
* persists is ALSO mirrored into the shared slot.
|
|
183
|
+
*
|
|
184
|
+
* Writes mirror automatically; reads do NOT adopt. That split is deliberate:
|
|
185
|
+
*
|
|
186
|
+
* - Mirroring on write is the right place because `save()` is where a proven
|
|
187
|
+
* credential lands, on every lane there is — interactive sign-in, the cold
|
|
188
|
+
* boot mint, the refresh scheduler, the 401 re-mint, shared-key recovery. One
|
|
189
|
+
* seam, no lane left out, and no new call site to forget.
|
|
190
|
+
* - Adopting on read would hide a change of WHO THIS APP IS SIGNED IN AS inside
|
|
191
|
+
* a storage primitive, and would run on every `load()`. Adoption is an
|
|
192
|
+
* explicit, once-per-boot cold-boot step instead (`shared-device-adopt`).
|
|
193
|
+
*
|
|
194
|
+
* `clear()` deliberately does NOT clear the shared slot. This app signing out is
|
|
195
|
+
* not authority over the device-wide join point: other apps may still be signed
|
|
196
|
+
* in on that same credential, and once the server session is really gone the
|
|
197
|
+
* credential mints `no_active_session` for everyone anyway.
|
|
198
|
+
*/
|
|
199
|
+
export declare function createSharedMirroringAuthStateStore(deps: {
|
|
200
|
+
local: AuthStateStore;
|
|
201
|
+
shared: SharedDeviceCredentialStore;
|
|
202
|
+
}): AuthStateStore;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "21.0.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -116,7 +116,7 @@
|
|
|
116
116
|
"dependencies": {
|
|
117
117
|
"@noble/ciphers": "^1.3.0",
|
|
118
118
|
"@noble/hashes": "^1.8.0",
|
|
119
|
-
"@oxyhq/contracts": "^0.
|
|
119
|
+
"@oxyhq/contracts": "^0.26.0",
|
|
120
120
|
"@oxyhq/protocol": "^0.2.0",
|
|
121
121
|
"@scure/bip39": "^1.6.0",
|
|
122
122
|
"@types/elliptic": "^6.4.18",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"invariant": "^2.2.4",
|
|
128
128
|
"jwt-decode": "^4.0.0",
|
|
129
129
|
"socket.io-client": "^4.8.1",
|
|
130
|
-
"tldts": "^7.4.
|
|
130
|
+
"tldts": "^7.4.10",
|
|
131
131
|
"zod": "^3.25.64"
|
|
132
132
|
},
|
|
133
133
|
"peerDependencies": {
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shared-device-adopt` — the cold-boot lane that lets a freshly installed
|
|
3
|
+
* official app join the device's EXISTING session with no QR and, crucially,
|
|
4
|
+
* with no access to the Commons private identity key.
|
|
5
|
+
*
|
|
6
|
+
* What is pinned here:
|
|
7
|
+
* - a fresh install adopts the shared credential and mints;
|
|
8
|
+
* - an app that already has a credential is never moved (upgrade order cannot
|
|
9
|
+
* sign anyone out, in either direction);
|
|
10
|
+
* - an UNREADABLE shared slot is not a fresh device — the lane skips and the
|
|
11
|
+
* legacy identity-key lane still gets its turn;
|
|
12
|
+
* - a failed adoption leaves the local store byte-for-byte as it was;
|
|
13
|
+
* - `sessionMode: 'identity'` never runs the lane at all;
|
|
14
|
+
* - web never runs it.
|
|
15
|
+
*/
|
|
16
|
+
import type { OxyServices } from '../../OxyServices';
|
|
17
|
+
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
18
|
+
import type { SessionLoginResponse } from '../../models/session';
|
|
19
|
+
import { runSessionColdBoot } from '../sessionColdBoot';
|
|
20
|
+
import type { DeviceSecretMintOutcome } from '../../session/refresh';
|
|
21
|
+
import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
|
|
22
|
+
import type {
|
|
23
|
+
SharedDeviceCredentialRead,
|
|
24
|
+
SharedDeviceCredentialStore,
|
|
25
|
+
} from '../../session/sharedDeviceCredential';
|
|
26
|
+
import { createMemoryIdentityPinStore } from '../../session/identityPin';
|
|
27
|
+
import type { IdentityBinding } from '../../session/identitySession';
|
|
28
|
+
|
|
29
|
+
const WEB = { isWeb: true, isNative: false };
|
|
30
|
+
const NATIVE = { isWeb: false, isNative: true };
|
|
31
|
+
|
|
32
|
+
const SHARED_CRED = { deviceId: 'dev-shared', deviceSecret: 'ds-shared' };
|
|
33
|
+
const OWN_CRED = { deviceId: 'dev-own', deviceSecret: 'ds-own' };
|
|
34
|
+
|
|
35
|
+
const MINT: DeviceTokenMintResponse = {
|
|
36
|
+
accessToken: 'access-joined',
|
|
37
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
38
|
+
nextDeviceSecret: SHARED_CRED.deviceSecret,
|
|
39
|
+
state: {
|
|
40
|
+
deviceId: SHARED_CRED.deviceId,
|
|
41
|
+
accounts: [{ accountId: 'user-shared', sessionId: 'sess-shared', authuser: 0 }],
|
|
42
|
+
activeAccountId: 'user-shared',
|
|
43
|
+
revision: 9,
|
|
44
|
+
updatedAt: 1_700_000_000_000,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** A real device-secret mint single-flight matching HttpService's. */
|
|
49
|
+
function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
|
|
50
|
+
let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
51
|
+
return (mint) => {
|
|
52
|
+
if (!inFlight) {
|
|
53
|
+
inFlight = mint().finally(() => {
|
|
54
|
+
inFlight = null;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return inFlight;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A 401 error shaped like `HttpService`/`handleError` output for the given body. */
|
|
62
|
+
function mint401(body: string): Error & { status: number } {
|
|
63
|
+
return Object.assign(new Error(body), { status: 401 });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function makeOxy(overrides: {
|
|
67
|
+
mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
|
|
68
|
+
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
69
|
+
} = {}) {
|
|
70
|
+
const setTokens = jest.fn();
|
|
71
|
+
const mintFromDeviceSecret = jest.fn(
|
|
72
|
+
overrides.mintFromDeviceSecret ?? (async () => MINT),
|
|
73
|
+
) as unknown as OxyServices['mintFromDeviceSecret'];
|
|
74
|
+
const signInWithSharedIdentity = jest.fn(
|
|
75
|
+
overrides.signInWithSharedIdentity ?? (async () => null),
|
|
76
|
+
) as unknown as OxyServices['signInWithSharedIdentity'];
|
|
77
|
+
const oxy = {
|
|
78
|
+
getBaseURL: () => 'https://api.oxy.so',
|
|
79
|
+
setTokens,
|
|
80
|
+
mintFromDeviceSecret,
|
|
81
|
+
signInWithSharedIdentity,
|
|
82
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
83
|
+
} as unknown as OxyServices;
|
|
84
|
+
return { oxy, setTokens, mintFromDeviceSecret, signInWithSharedIdentity };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeSharedSlot(initial: SharedDeviceCredentialRead) {
|
|
88
|
+
let current = initial;
|
|
89
|
+
const read = jest.fn(async () => current);
|
|
90
|
+
const publish = jest.fn(async () => true);
|
|
91
|
+
const clear = jest.fn(async () => {
|
|
92
|
+
current = { state: 'absent' };
|
|
93
|
+
});
|
|
94
|
+
const store: SharedDeviceCredentialStore = { read, publish, clear };
|
|
95
|
+
return { store, read, publish, clear };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** An identity binding that resolves a local key, for the identity-mode case. */
|
|
99
|
+
function makeIdentityBinding(): IdentityBinding {
|
|
100
|
+
return {
|
|
101
|
+
pinStore: createMemoryIdentityPinStore(),
|
|
102
|
+
getPublicKey: async () => 'pub-identity',
|
|
103
|
+
signMessage: async () => 'sig-identity',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('cold boot — shared-device-adopt', () => {
|
|
108
|
+
test('a fresh install adopts the shared credential and mints a session', async () => {
|
|
109
|
+
const store = createMemoryAuthStateStore();
|
|
110
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
111
|
+
const { oxy, mintFromDeviceSecret, setTokens } = makeOxy();
|
|
112
|
+
|
|
113
|
+
const outcome = await runSessionColdBoot({
|
|
114
|
+
oxy,
|
|
115
|
+
store,
|
|
116
|
+
platform: NATIVE,
|
|
117
|
+
sharedDeviceCredential: slot.store,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'shared-device-adopt' });
|
|
121
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith(SHARED_CRED.deviceId, SHARED_CRED.deviceSecret);
|
|
122
|
+
expect(setTokens).toHaveBeenCalledWith(MINT.accessToken);
|
|
123
|
+
// The adopted credential is now this app's own, so the next boot takes the
|
|
124
|
+
// faster `device-secret-mint` lane.
|
|
125
|
+
expect(await store.load()).toMatchObject({
|
|
126
|
+
deviceId: SHARED_CRED.deviceId,
|
|
127
|
+
deviceSecret: SHARED_CRED.deviceSecret,
|
|
128
|
+
sessionId: 'sess-shared',
|
|
129
|
+
userId: 'user-shared',
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('the identity key is never touched when the shared credential works', async () => {
|
|
134
|
+
// The whole point of the separation: an ordinary app joins the device session
|
|
135
|
+
// without ever asking for the key that signs identity approvals.
|
|
136
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
137
|
+
const { oxy, signInWithSharedIdentity } = makeOxy();
|
|
138
|
+
|
|
139
|
+
await runSessionColdBoot({
|
|
140
|
+
oxy,
|
|
141
|
+
store: createMemoryAuthStateStore(),
|
|
142
|
+
platform: NATIVE,
|
|
143
|
+
sharedDeviceCredential: slot.store,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('an app that already holds its own credential is not moved onto the shared one', async () => {
|
|
150
|
+
// Upgrade order must not sign anyone out. This app's own session wins.
|
|
151
|
+
const store = createMemoryAuthStateStore();
|
|
152
|
+
await store.save({ sessionId: 'sess-own', userId: 'user-own', ...OWN_CRED });
|
|
153
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
154
|
+
const { oxy, mintFromDeviceSecret } = makeOxy({
|
|
155
|
+
mintFromDeviceSecret: (async () => ({
|
|
156
|
+
...MINT,
|
|
157
|
+
nextDeviceSecret: OWN_CRED.deviceSecret,
|
|
158
|
+
state: { ...MINT.state, deviceId: OWN_CRED.deviceId },
|
|
159
|
+
})) as unknown as OxyServices['mintFromDeviceSecret'],
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const outcome = await runSessionColdBoot({
|
|
163
|
+
oxy,
|
|
164
|
+
store,
|
|
165
|
+
platform: NATIVE,
|
|
166
|
+
sharedDeviceCredential: slot.store,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
|
|
170
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith(OWN_CRED.deviceId, OWN_CRED.deviceSecret);
|
|
171
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalledWith(SHARED_CRED.deviceId, SHARED_CRED.deviceSecret);
|
|
172
|
+
expect(await store.load()).toMatchObject({ deviceId: OWN_CRED.deviceId });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('an UNREADABLE shared slot is not treated as a fresh device', async () => {
|
|
176
|
+
// A locked keystore must never authorise anything. The lane skips, the store
|
|
177
|
+
// is untouched, and the legacy identity lane still gets its turn — which is
|
|
178
|
+
// what keeps a momentarily-locked device from silently onboarding again.
|
|
179
|
+
const store = createMemoryAuthStateStore();
|
|
180
|
+
const slot = makeSharedSlot({ state: 'unavailable', cause: new Error('keystore locked') });
|
|
181
|
+
const sharedKeySession = {
|
|
182
|
+
sessionId: 'sess-legacy',
|
|
183
|
+
user: { id: 'user-legacy' },
|
|
184
|
+
accessToken: 'access-legacy',
|
|
185
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
186
|
+
deviceId: 'dev-legacy',
|
|
187
|
+
deviceSecret: 'ds-legacy',
|
|
188
|
+
} as unknown as SessionLoginResponse;
|
|
189
|
+
const { oxy, signInWithSharedIdentity } = makeOxy({
|
|
190
|
+
signInWithSharedIdentity: (async () => sharedKeySession) as unknown as OxyServices['signInWithSharedIdentity'],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const outcome = await runSessionColdBoot({
|
|
194
|
+
oxy,
|
|
195
|
+
store,
|
|
196
|
+
platform: NATIVE,
|
|
197
|
+
sharedDeviceCredential: slot.store,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(slot.publish).not.toHaveBeenCalled();
|
|
201
|
+
expect(slot.clear).not.toHaveBeenCalled();
|
|
202
|
+
expect(signInWithSharedIdentity).toHaveBeenCalled();
|
|
203
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'shared-key-signin' });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('an empty shared slot falls through to the legacy identity lane', async () => {
|
|
207
|
+
const slot = makeSharedSlot({ state: 'absent' });
|
|
208
|
+
const { oxy, signInWithSharedIdentity } = makeOxy();
|
|
209
|
+
|
|
210
|
+
const outcome = await runSessionColdBoot({
|
|
211
|
+
oxy,
|
|
212
|
+
store: createMemoryAuthStateStore(),
|
|
213
|
+
platform: NATIVE,
|
|
214
|
+
sharedDeviceCredential: slot.store,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(signInWithSharedIdentity).toHaveBeenCalled();
|
|
218
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('a rejected shared credential is reverted locally and cleared from the slot', async () => {
|
|
222
|
+
// `invalid_device_secret` is the one positive proof that the exact shared
|
|
223
|
+
// bytes are dead. Reverting keeps this app where it was; clearing the slot is
|
|
224
|
+
// what stops a dead credential from blocking every future install.
|
|
225
|
+
const store = createMemoryAuthStateStore();
|
|
226
|
+
const before: PersistedAuthState = { sessionId: 'sess-stale', userId: 'user-stale' };
|
|
227
|
+
await store.save(before);
|
|
228
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
229
|
+
const { oxy } = makeOxy({
|
|
230
|
+
mintFromDeviceSecret: (async () => {
|
|
231
|
+
throw mint401('invalid_device_secret');
|
|
232
|
+
}) as unknown as OxyServices['mintFromDeviceSecret'],
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const outcome = await runSessionColdBoot({
|
|
236
|
+
oxy,
|
|
237
|
+
store,
|
|
238
|
+
platform: NATIVE,
|
|
239
|
+
sharedDeviceCredential: slot.store,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
243
|
+
expect(slot.clear).toHaveBeenCalledTimes(1);
|
|
244
|
+
expect(await store.load()).toEqual(before);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('a transient mint failure reverts without clearing the shared slot', async () => {
|
|
248
|
+
// A network blip says nothing about the credential. Wiping the device-wide
|
|
249
|
+
// join point on an ambiguous failure is the deploy-window bug, one layer up.
|
|
250
|
+
const store = createMemoryAuthStateStore();
|
|
251
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
252
|
+
const { oxy } = makeOxy({
|
|
253
|
+
mintFromDeviceSecret: (async () => {
|
|
254
|
+
throw new Error('network down');
|
|
255
|
+
}) as unknown as OxyServices['mintFromDeviceSecret'],
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await runSessionColdBoot({ oxy, store, platform: NATIVE, sharedDeviceCredential: slot.store });
|
|
259
|
+
|
|
260
|
+
expect(slot.clear).not.toHaveBeenCalled();
|
|
261
|
+
expect(await store.load()).toBeNull();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('the lane does not run on web', async () => {
|
|
265
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
266
|
+
const { oxy, mintFromDeviceSecret } = makeOxy();
|
|
267
|
+
|
|
268
|
+
const outcome = await runSessionColdBoot({
|
|
269
|
+
oxy,
|
|
270
|
+
store: createMemoryAuthStateStore(),
|
|
271
|
+
platform: WEB,
|
|
272
|
+
sharedDeviceCredential: slot.store,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(slot.read).not.toHaveBeenCalled();
|
|
276
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
277
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test('the lane does not run when the device reports itself offline', async () => {
|
|
281
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
282
|
+
const { oxy } = makeOxy();
|
|
283
|
+
|
|
284
|
+
await runSessionColdBoot({
|
|
285
|
+
oxy,
|
|
286
|
+
store: createMemoryAuthStateStore(),
|
|
287
|
+
platform: NATIVE,
|
|
288
|
+
isOffline: () => true,
|
|
289
|
+
sharedDeviceCredential: slot.store,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(slot.read).not.toHaveBeenCalled();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test('identity mode never reads the shared slot', async () => {
|
|
296
|
+
// The slot belongs to whichever principal signed in on this device. An
|
|
297
|
+
// identity-bound client resolves its session from the local key alone —
|
|
298
|
+
// adopting a device credential is the drift that mode exists to prevent.
|
|
299
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
300
|
+
const { oxy } = makeOxy();
|
|
301
|
+
|
|
302
|
+
await runSessionColdBoot({
|
|
303
|
+
oxy,
|
|
304
|
+
store: createMemoryAuthStateStore(),
|
|
305
|
+
platform: NATIVE,
|
|
306
|
+
sessionMode: 'identity',
|
|
307
|
+
identity: makeIdentityBinding(),
|
|
308
|
+
sharedDeviceCredential: slot.store,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
expect(slot.read).not.toHaveBeenCalled();
|
|
312
|
+
expect(slot.publish).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('omitting the slot leaves the boot chain exactly as it was', async () => {
|
|
316
|
+
const slot = makeSharedSlot({ state: 'present', credential: SHARED_CRED });
|
|
317
|
+
const { oxy, signInWithSharedIdentity } = makeOxy();
|
|
318
|
+
|
|
319
|
+
const outcome = await runSessionColdBoot({ oxy, store: createMemoryAuthStateStore(), platform: NATIVE });
|
|
320
|
+
|
|
321
|
+
expect(slot.read).not.toHaveBeenCalled();
|
|
322
|
+
expect(signInWithSharedIdentity).toHaveBeenCalled();
|
|
323
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
324
|
+
});
|
|
325
|
+
});
|