@oxyhq/core 12.10.6 → 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/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/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__/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/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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { OxyServices } from '../OxyServices';
|
|
2
2
|
import type { AuthRefreshHandler } from '../HttpService';
|
|
3
3
|
import type { AuthStateStore } from './authStateStore';
|
|
4
|
+
import type { IdentityPin } from './identityPin';
|
|
5
|
+
import { type IdentityBinding } from './identitySession';
|
|
4
6
|
/**
|
|
5
7
|
* Lead time (ms) before access-token expiry at which the proactive scheduler
|
|
6
8
|
* re-mints. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
|
|
@@ -14,9 +16,19 @@ export interface RefreshDeps {
|
|
|
14
16
|
/**
|
|
15
17
|
* Whether to fall back to the native shared-keychain re-mint (arm 2) when the
|
|
16
18
|
* persisted secret is absent / rejected. Defaults to `isNative()` — web has no
|
|
17
|
-
* shared keychain. Exposed for tests.
|
|
19
|
+
* shared keychain. Exposed for tests. IGNORED when {@link identity} is set: an
|
|
20
|
+
* identity-bound client must never adopt the CROSS-APP shared slot, which may
|
|
21
|
+
* hold a different identity than this device's primary key.
|
|
18
22
|
*/
|
|
19
23
|
allowSharedKeyFallback?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Identity-bound (pinned) mode. When present, every re-mint targets the
|
|
26
|
+
* PINNED account — resolved fresh from the pin store on each call, since a
|
|
27
|
+
* re-established session can move it — instead of the device's active
|
|
28
|
+
* account, and arm 2 becomes the PRIMARY-key identity sign-in rather than the
|
|
29
|
+
* shared-keychain one.
|
|
30
|
+
*/
|
|
31
|
+
identity?: IdentityBinding;
|
|
20
32
|
}
|
|
21
33
|
/**
|
|
22
34
|
* The outcome of ONE device-secret mint attempt (arm 1). Discriminated so both
|
|
@@ -28,6 +40,10 @@ export interface RefreshDeps {
|
|
|
28
40
|
* diverged (another tab/device rotated it past the grace window).
|
|
29
41
|
* - `no-session` — 401 `no_active_session`: the device is known but has no live
|
|
30
42
|
* session (authoritative signed-out).
|
|
43
|
+
* - `account-not-on-device` — 401 `account_not_on_device` for a PINNED mint: the
|
|
44
|
+
* pinned account is not (or no longer) a live account of this device session.
|
|
45
|
+
* The device secret is FINE — it is the identity binding that went stale, so
|
|
46
|
+
* the caller must re-establish from the local key, never drop the credential.
|
|
31
47
|
* - `transient` — network / 5xx; keep the secret, a later attempt can succeed.
|
|
32
48
|
* - `persist-failed` — the mint succeeded (the SERVER rotated the secret) but
|
|
33
49
|
* the rotated `nextDeviceSecret` could NOT be durably persisted. The token is
|
|
@@ -45,6 +61,8 @@ export type DeviceSecretMintOutcome = {
|
|
|
45
61
|
status: 'invalid-secret';
|
|
46
62
|
} | {
|
|
47
63
|
status: 'no-session';
|
|
64
|
+
} | {
|
|
65
|
+
status: 'account-not-on-device';
|
|
48
66
|
} | {
|
|
49
67
|
status: 'transient';
|
|
50
68
|
} | {
|
|
@@ -70,10 +88,18 @@ export type DeviceSecretMintOutcome = {
|
|
|
70
88
|
* planting. This function performs NO store mutation on failure — the caller
|
|
71
89
|
* applies the drop/clear policy (which differs web vs native) from the returned
|
|
72
90
|
* status.
|
|
91
|
+
*
|
|
92
|
+
* `pin` makes the mint IDENTITY-BOUND: the request carries the pinned
|
|
93
|
+
* `accountId` (so the server mints that account's token without touching
|
|
94
|
+
* `activeAccountId`), and the persisted `sessionId`/`userId` are resolved from
|
|
95
|
+
* the PINNED account entry — never from `state.activeAccountId`, whose drift is
|
|
96
|
+
* exactly what the pin exists to stop.
|
|
73
97
|
*/
|
|
74
98
|
export declare function refreshDeviceSecretArm(deps: {
|
|
75
99
|
oxy: OxyServices;
|
|
76
100
|
store: AuthStateStore;
|
|
101
|
+
/** The identity pin, when this client is identity-bound. */
|
|
102
|
+
pin?: IdentityPin | null;
|
|
77
103
|
}): Promise<DeviceSecretMintOutcome>;
|
|
78
104
|
/**
|
|
79
105
|
* Re-mint the persisted session and return the fresh access token, or `null`
|
|
@@ -91,6 +117,11 @@ export declare function refreshDeviceSecretArm(deps: {
|
|
|
91
117
|
* recovered `{deviceId, deviceSecret, …}` is PERSISTED so the fast device-secret
|
|
92
118
|
* lane is repopulated (mirrors the cold boot's `shared-key-signin` step) — an
|
|
93
119
|
* in-session shared-key recovery must not leave the fast-lane credential empty.
|
|
120
|
+
*
|
|
121
|
+
* IDENTITY-BOUND clients (`deps.identity`) run a different arm 2: the
|
|
122
|
+
* shared-keychain lane is DISABLED (its cross-app slot may hold a different
|
|
123
|
+
* identity) and replaced by {@link establishIdentitySession}, which re-signs a
|
|
124
|
+
* challenge with the PRIMARY local key and rewrites the pin. Arm 1 is pinned.
|
|
94
125
|
*/
|
|
95
126
|
export declare function refreshPersistedSession(deps: RefreshDeps): Promise<string | null>;
|
|
96
127
|
/**
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic "Sign in with Oxy" delivery selection (issue #691, Phase 4).
|
|
3
|
+
*
|
|
4
|
+
* The user performs ONE action ("Continue with Oxy"); Oxy — not the user —
|
|
5
|
+
* decides how the approval request reaches their Commons identity. This module
|
|
6
|
+
* is that decision, and nothing else: a single pure function mapping the facts
|
|
7
|
+
* the caller has gathered onto exactly ONE primary route.
|
|
8
|
+
*
|
|
9
|
+
* Deliberate design constraints:
|
|
10
|
+
*
|
|
11
|
+
* - **Pure.** No I/O, no platform sniffing, no clock, no globals. Every input
|
|
12
|
+
* is passed in by the caller, which is what makes the decision exhaustively
|
|
13
|
+
* unit-testable and identical on web, native, and server.
|
|
14
|
+
* - **One route, never a chain.** The result is the PRIMARY route only.
|
|
15
|
+
* Alternatives stay hidden behind a "Having trouble?" affordance and are
|
|
16
|
+
* revealed by the UI only once the primary route fails or is unavailable —
|
|
17
|
+
* the SDK never silently cascades from one delivery surface to the next.
|
|
18
|
+
* - **Fail-safe to QR.** QR is the route that works with no prior knowledge of
|
|
19
|
+
* the device, so every ambiguous or malformed input degrades to it rather
|
|
20
|
+
* than to a route that could leave the user staring at a dead end.
|
|
21
|
+
*
|
|
22
|
+
* Every route drives the SAME `AuthSession`, resolved from the same public
|
|
23
|
+
* `authorizeCode`; the route only decides how that code travels.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* The kind of surface the sign-in was initiated from.
|
|
27
|
+
*
|
|
28
|
+
* `'unknown'` is a first-class value, not an error: a caller that cannot
|
|
29
|
+
* confidently classify the surface must say so rather than guess, and an
|
|
30
|
+
* unknown surface never opts into the deep-link route (a custom-scheme
|
|
31
|
+
* navigation that does not resolve is a dead end with no automatic way back).
|
|
32
|
+
*/
|
|
33
|
+
export type CommonsDeliveryPlatform = 'mobile' | 'desktop' | 'unknown';
|
|
34
|
+
/**
|
|
35
|
+
* The single primary route chosen for this request.
|
|
36
|
+
*
|
|
37
|
+
* - `'open-commons'` — navigate to the verified Commons app/universal link on
|
|
38
|
+
* THIS device and let the user confirm there.
|
|
39
|
+
* - `'await-push'` — the request was pushed to a known Commons installation;
|
|
40
|
+
* show "Check Commons on your phone" and wait for the authorization.
|
|
41
|
+
* - `'qr'` — render the QR carrying the public `authorizeCode` for the user to
|
|
42
|
+
* scan with Commons on another device.
|
|
43
|
+
*/
|
|
44
|
+
export type CommonsDeliveryRoute = 'open-commons' | 'await-push' | 'qr';
|
|
45
|
+
/**
|
|
46
|
+
* The facts the caller must gather before asking for a route. All of them are
|
|
47
|
+
* observations, never decisions — the caller owns the platform detection, the
|
|
48
|
+
* app-link verification, and the delivery round-trip; this module owns only the
|
|
49
|
+
* choice between them.
|
|
50
|
+
*/
|
|
51
|
+
export interface CommonsDeliveryFacts {
|
|
52
|
+
/** Which surface the sign-in was initiated from (see {@link CommonsDeliveryPlatform}). */
|
|
53
|
+
platform: CommonsDeliveryPlatform;
|
|
54
|
+
/**
|
|
55
|
+
* `true` only when a VERIFIED Commons app/universal link can be opened on
|
|
56
|
+
* this very device (an installed, link-verified Commons). "The user probably
|
|
57
|
+
* has Commons somewhere" is not this fact — that is what `pushTargets`
|
|
58
|
+
* expresses.
|
|
59
|
+
*/
|
|
60
|
+
commonsAvailable: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* How many eligible Commons installations the server said it delivered the
|
|
63
|
+
* request to — the `targets` field of `deliverCommonsSignIn`. Zero is a
|
|
64
|
+
* NORMAL outcome (no capable Commons install is registered for this
|
|
65
|
+
* identity), never an error; it simply means push is not a usable route.
|
|
66
|
+
*/
|
|
67
|
+
pushTargets: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Choose the ONE primary delivery route for a "Sign in with Oxy" request.
|
|
71
|
+
*
|
|
72
|
+
* Decision order (issue #691, "Automatic delivery selection"):
|
|
73
|
+
*
|
|
74
|
+
* 1. Mobile with a verified Commons link available → open Commons directly.
|
|
75
|
+
* No push is needed when the identity is already reachable on this device.
|
|
76
|
+
* 2. At least one eligible Commons installation was pushed to → await the push.
|
|
77
|
+
* 3. Otherwise → QR.
|
|
78
|
+
*
|
|
79
|
+
* A caller that already knows it is on step 1 does not need to call
|
|
80
|
+
* `deliverCommonsSignIn` at all; every other caller runs the delivery
|
|
81
|
+
* round-trip first and passes its `targets` in here.
|
|
82
|
+
*
|
|
83
|
+
* `pushTargets` is server-derived, so it is validated rather than trusted: a
|
|
84
|
+
* negative, fractional, non-finite, or otherwise malformed count is treated as
|
|
85
|
+
* "no targets" and degrades to QR instead of parking the user on a "check your
|
|
86
|
+
* phone" screen that nothing will ever wake.
|
|
87
|
+
*/
|
|
88
|
+
export declare function selectCommonsDelivery(facts: CommonsDeliveryFacts): CommonsDeliveryRoute;
|
|
89
|
+
/**
|
|
90
|
+
* Map a `deliverCommonsSignIn` result onto the `pushTargets` fact for
|
|
91
|
+
* {@link selectCommonsDelivery}. The server reports eligible install *count*
|
|
92
|
+
* separately from whether any push was accepted — a transport failure can leave
|
|
93
|
+
* `delivered: false` with `targets > 0`, which must NOT park the user on
|
|
94
|
+
* "check your phone".
|
|
95
|
+
*/
|
|
96
|
+
export declare function pushTargetsFromDelivery(result: {
|
|
97
|
+
delivered: boolean;
|
|
98
|
+
targets: number;
|
|
99
|
+
}): number;
|
|
100
|
+
/**
|
|
101
|
+
* Classify the current runtime surface for delivery selection. Callers that
|
|
102
|
+
* cannot confidently detect the platform should not use this — pass
|
|
103
|
+
* `'unknown'` explicitly instead.
|
|
104
|
+
*/
|
|
105
|
+
export declare function commonsDeliveryPlatform(): CommonsDeliveryPlatform;
|
|
@@ -47,6 +47,18 @@ export interface BuildOAuthAuthorizeUrlParams {
|
|
|
47
47
|
* (no UI when the IdP hub already has a session + grant).
|
|
48
48
|
*/
|
|
49
49
|
prompt?: 'none' | 'login' | 'consent';
|
|
50
|
+
/**
|
|
51
|
+
* How the IdP should deliver the authorization response. Omitted (the
|
|
52
|
+
* default) means the ordinary top-level redirect back to `redirectUri`.
|
|
53
|
+
*
|
|
54
|
+
* `web_message` asks the IdP to `postMessage` the result to its opener
|
|
55
|
+
* instead, so a popup sign-in never navigates the relying party's tab. It is
|
|
56
|
+
* a REQUEST, not a guarantee: an IdP with no opener still redirects, which is
|
|
57
|
+
* why the popup transport must handle both outcomes. Only the authorization
|
|
58
|
+
* code, the `state`, and a typed OAuth error ever cross that channel — never
|
|
59
|
+
* a token, device secret, or the PKCE verifier, which the opener keeps.
|
|
60
|
+
*/
|
|
61
|
+
responseMode?: 'web_message';
|
|
50
62
|
}
|
|
51
63
|
/**
|
|
52
64
|
* Compute the PKCE S256 `code_challenge` for a given verifier:
|
|
@@ -81,6 +93,8 @@ export declare function buildOAuthAuthorizeUrl(params: BuildOAuthAuthorizeUrlPar
|
|
|
81
93
|
export declare const OXY_OAUTH_STATE_STORAGE_KEY = "oxy_oauth_state";
|
|
82
94
|
/** `sessionStorage` key for the PKCE `code_verifier` across an authorize redirect. */
|
|
83
95
|
export declare const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = "oxy_oauth_code_verifier";
|
|
96
|
+
/** `sessionStorage` key — the exact `redirect_uri` sent on the authorize request. */
|
|
97
|
+
export declare const OXY_OAUTH_REDIRECT_URI_STORAGE_KEY = "oxy.oauth_redirect_uri";
|
|
84
98
|
/** `sessionStorage` key — at most one silent OAuth attempt per navigation. */
|
|
85
99
|
export declare const OXY_SILENT_OAUTH_ATTEMPTED_KEY = "oxy.silent_oauth_attempted";
|
|
86
100
|
/** `sessionStorage` key — blocks further cross-origin auto-restore in this tab. */
|
|
@@ -100,6 +114,11 @@ export declare const OXY_OAUTH_RETURN_PATH_STORAGE_KEY = "oxy.oauth_return_path"
|
|
|
100
114
|
* out-of-band and restored on return.
|
|
101
115
|
*/
|
|
102
116
|
export declare function normalizeOAuthRedirectUri(input: string): string;
|
|
117
|
+
/**
|
|
118
|
+
* Collapse `https://app.example/` → `https://app.example` for OAuth binding.
|
|
119
|
+
* Path-qualified redirect URIs are preserved — matches the API token exchange.
|
|
120
|
+
*/
|
|
121
|
+
export declare function canonicalizeOAuthRedirectUri(redirectUri: string): string;
|
|
103
122
|
/**
|
|
104
123
|
* Remember the page the user was on before a full-page authorize redirect.
|
|
105
124
|
*
|
|
@@ -123,11 +142,12 @@ export declare function persistOAuthReturnPath(path: string): void;
|
|
|
123
142
|
*/
|
|
124
143
|
export declare function consumeOAuthReturnPath(): string | null;
|
|
125
144
|
/** Persist the OAuth handshake for a full-page redirect return (web only). */
|
|
126
|
-
export declare function persistOAuthHandshake(state: string, codeVerifier: string): boolean;
|
|
145
|
+
export declare function persistOAuthHandshake(state: string, codeVerifier: string, redirectUri?: string): boolean;
|
|
127
146
|
/** Read the persisted OAuth handshake, or `null` when absent. */
|
|
128
147
|
export declare function readOAuthHandshake(): {
|
|
129
148
|
state: string;
|
|
130
149
|
codeVerifier: string;
|
|
150
|
+
redirectUri?: string;
|
|
131
151
|
} | null;
|
|
132
152
|
/** Drop persisted OAuth handshake keys after a successful or aborted return. */
|
|
133
153
|
export declare function clearOAuthHandshake(): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.11.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",
|
|
@@ -115,7 +115,7 @@
|
|
|
115
115
|
"dependencies": {
|
|
116
116
|
"@noble/ciphers": "^1.3.0",
|
|
117
117
|
"@noble/hashes": "^1.8.0",
|
|
118
|
-
"@oxyhq/contracts": "^0.
|
|
118
|
+
"@oxyhq/contracts": "^0.18.0",
|
|
119
119
|
"@oxyhq/protocol": "^0.1.5",
|
|
120
120
|
"bip39": "^3.1.0",
|
|
121
121
|
"buffer": "^6.0.3",
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cold boot in IDENTITY mode — the identity vault's boot.
|
|
3
|
+
*
|
|
4
|
+
* Invariants under test:
|
|
5
|
+
* - a persisted warm token that belongs to ANOTHER account is never planted;
|
|
6
|
+
* the boot falls through to the PINNED mint instead;
|
|
7
|
+
* - the mint carries the pinned `accountId`, and the resolved session is the
|
|
8
|
+
* pinned account even while the device is switched elsewhere;
|
|
9
|
+
* - `identity-key-signin` (the PRIMARY local key) replaces `shared-key-signin`
|
|
10
|
+
* (the CROSS-APP shared slot) — and the reverse in account mode;
|
|
11
|
+
* - with no verified pin the (unpinned) mint lane is skipped entirely rather
|
|
12
|
+
* than adopting whichever account the device is currently switched to.
|
|
13
|
+
*/
|
|
14
|
+
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
15
|
+
import type { OxyServices } from '../../OxyServices';
|
|
16
|
+
import type { SessionLoginResponse } from '../../models/session';
|
|
17
|
+
import type { AuthChallenge } from '../../crypto/signatureService';
|
|
18
|
+
import { runSessionColdBoot } from '../sessionColdBoot';
|
|
19
|
+
import type { DeviceSecretMintOutcome } from '../../session/refresh';
|
|
20
|
+
import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
|
|
21
|
+
import { createMemoryIdentityPinStore, type IdentityPin } from '../../session/identityPin';
|
|
22
|
+
import type { IdentityBinding } from '../../session/identitySession';
|
|
23
|
+
|
|
24
|
+
const PUBLIC_KEY = `02${'a'.repeat(64)}`;
|
|
25
|
+
const OTHER_PUBLIC_KEY = `03${'b'.repeat(64)}`;
|
|
26
|
+
const PIN: IdentityPin = { publicKey: PUBLIC_KEY, accountId: 'vault-user' };
|
|
27
|
+
|
|
28
|
+
const NATIVE = { isWeb: false, isNative: true };
|
|
29
|
+
|
|
30
|
+
/** A minimal jwt-decode-able token whose `userId` claim is `accountId`. */
|
|
31
|
+
function jwtFor(accountId: string): string {
|
|
32
|
+
const payload = Buffer.from(JSON.stringify({ userId: accountId })).toString('base64url');
|
|
33
|
+
return `h.${payload}.s`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Comfortably beyond the 60s refresh lead window. */
|
|
37
|
+
const farFuture = (): string => new Date(Date.now() + 3_600_000).toISOString();
|
|
38
|
+
|
|
39
|
+
function makeMintSingleFlight(): (mint: () => Promise<DeviceSecretMintOutcome>) => Promise<DeviceSecretMintOutcome> {
|
|
40
|
+
let inFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
41
|
+
return (mint) => {
|
|
42
|
+
if (!inFlight) {
|
|
43
|
+
inFlight = mint().finally(() => {
|
|
44
|
+
inFlight = null;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return inFlight;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The device is switched to `other-user`; the pinned account is still a member. */
|
|
52
|
+
const MINT_WHILE_SWITCHED: DeviceTokenMintResponse = {
|
|
53
|
+
accessToken: jwtFor('vault-user'),
|
|
54
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
55
|
+
nextDeviceSecret: 'ds-next-secret',
|
|
56
|
+
state: {
|
|
57
|
+
deviceId: 'dev-1',
|
|
58
|
+
accounts: [
|
|
59
|
+
{ accountId: 'vault-user', sessionId: 'sess-vault-new', authuser: 0 },
|
|
60
|
+
{ accountId: 'other-user', sessionId: 'sess-other', authuser: 1 },
|
|
61
|
+
],
|
|
62
|
+
activeAccountId: 'other-user',
|
|
63
|
+
revision: 9,
|
|
64
|
+
updatedAt: 1_700_000_000_000,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const IDENTITY_SESSION: SessionLoginResponse = {
|
|
69
|
+
sessionId: 'sess-identity',
|
|
70
|
+
deviceId: 'dev-1',
|
|
71
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
72
|
+
accessToken: 'access-identity',
|
|
73
|
+
deviceSecret: 'ds-identity',
|
|
74
|
+
user: { id: 'vault-user', username: 'vault', name: {} },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const SHARED_SESSION: SessionLoginResponse = {
|
|
78
|
+
sessionId: 'sess-shared',
|
|
79
|
+
deviceId: 'dev-1',
|
|
80
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
81
|
+
accessToken: 'access-shared',
|
|
82
|
+
user: { id: 'shared-user', username: 'shared', name: {} },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
interface OxyOverrides {
|
|
86
|
+
mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
|
|
87
|
+
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
88
|
+
requestChallenge?: OxyServices['requestChallenge'];
|
|
89
|
+
verifyChallenge?: OxyServices['verifyChallenge'];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function makeOxy(overrides: OxyOverrides = {}): { oxy: OxyServices; setTokens: jest.Mock } {
|
|
93
|
+
const setTokens = jest.fn();
|
|
94
|
+
const oxy = {
|
|
95
|
+
getBaseURL: () => 'https://api.oxy.so',
|
|
96
|
+
setTokens,
|
|
97
|
+
mintFromDeviceSecret:
|
|
98
|
+
overrides.mintFromDeviceSecret
|
|
99
|
+
?? (async () => {
|
|
100
|
+
throw new Error('mintFromDeviceSecret not stubbed');
|
|
101
|
+
}),
|
|
102
|
+
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
103
|
+
requestChallenge:
|
|
104
|
+
overrides.requestChallenge
|
|
105
|
+
?? (async () => ({ challenge: 'chal-1', expiresAt: '2030-01-01T00:00:00.000Z' })),
|
|
106
|
+
verifyChallenge: overrides.verifyChallenge ?? (async () => IDENTITY_SESSION),
|
|
107
|
+
httpService: { runSingleFlightDeviceSecretMint: makeMintSingleFlight() },
|
|
108
|
+
} as unknown as OxyServices;
|
|
109
|
+
return { oxy, setTokens };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function signWith(publicKey: string): (challenge: string) => Promise<AuthChallenge> {
|
|
113
|
+
return async (challenge) => ({
|
|
114
|
+
challenge: `sig(${challenge})`,
|
|
115
|
+
publicKey,
|
|
116
|
+
timestamp: 1_700_000_000_000,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** An identity binding, optionally pre-seeded with a pin. */
|
|
121
|
+
async function makeBinding(options: { pin?: IdentityPin; publicKey?: string } = {}): Promise<IdentityBinding> {
|
|
122
|
+
const publicKey = options.publicKey ?? PUBLIC_KEY;
|
|
123
|
+
const pinStore = createMemoryIdentityPinStore();
|
|
124
|
+
if (options.pin) {
|
|
125
|
+
await pinStore.save(options.pin);
|
|
126
|
+
}
|
|
127
|
+
return { pinStore, readPublicKey: async () => publicKey, signChallenge: signWith(publicKey) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function seedStore(extra: Partial<PersistedAuthState> = {}) {
|
|
131
|
+
const store = createMemoryAuthStateStore();
|
|
132
|
+
await store.save({
|
|
133
|
+
sessionId: 'sess-vault',
|
|
134
|
+
userId: 'vault-user',
|
|
135
|
+
deviceId: 'dev-1',
|
|
136
|
+
deviceSecret: 'ds-secret-orig',
|
|
137
|
+
...extra,
|
|
138
|
+
});
|
|
139
|
+
return store;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('runSessionColdBoot — identity mode: warm-token-plant', () => {
|
|
143
|
+
it('plants a warm token that belongs to the pinned account', async () => {
|
|
144
|
+
const store = await seedStore({ accessToken: jwtFor('vault-user'), expiresAt: farFuture() });
|
|
145
|
+
const { oxy, setTokens } = makeOxy();
|
|
146
|
+
|
|
147
|
+
const outcome = await runSessionColdBoot({
|
|
148
|
+
oxy,
|
|
149
|
+
store,
|
|
150
|
+
platform: NATIVE,
|
|
151
|
+
sessionMode: 'identity',
|
|
152
|
+
identity: await makeBinding({ pin: PIN }),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
|
|
156
|
+
expect(setTokens).toHaveBeenCalledWith(jwtFor('vault-user'));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('does NOT plant a warm token minted for ANOTHER account — falls through to the pinned mint', async () => {
|
|
160
|
+
// The durable drift: an account-mode re-mint rewrote the persisted session to
|
|
161
|
+
// `other-user` while the device was switched. Identity mode must reject it.
|
|
162
|
+
const store = await seedStore({
|
|
163
|
+
sessionId: 'sess-other',
|
|
164
|
+
userId: 'other-user',
|
|
165
|
+
accessToken: jwtFor('other-user'),
|
|
166
|
+
expiresAt: farFuture(),
|
|
167
|
+
});
|
|
168
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
|
|
169
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
170
|
+
|
|
171
|
+
const outcome = await runSessionColdBoot({
|
|
172
|
+
oxy,
|
|
173
|
+
store,
|
|
174
|
+
platform: NATIVE,
|
|
175
|
+
sessionMode: 'identity',
|
|
176
|
+
identity: await makeBinding({ pin: PIN }),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(setTokens).not.toHaveBeenCalledWith(jwtFor('other-user'));
|
|
180
|
+
expect(outcome).toMatchObject({
|
|
181
|
+
kind: 'session',
|
|
182
|
+
via: 'device-secret-mint',
|
|
183
|
+
session: { sessionId: 'sess-vault-new', userId: 'vault-user' },
|
|
184
|
+
});
|
|
185
|
+
expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-1', 'ds-secret-orig', {
|
|
186
|
+
accountId: 'vault-user',
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('does NOT plant a warm token whose persisted identity matches but whose CLAIM does not', async () => {
|
|
191
|
+
// Belt-and-braces: `userId` says the pinned account, the token itself does not.
|
|
192
|
+
const store = await seedStore({ accessToken: jwtFor('other-user'), expiresAt: farFuture() });
|
|
193
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
|
|
194
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
195
|
+
|
|
196
|
+
const outcome = await runSessionColdBoot({
|
|
197
|
+
oxy,
|
|
198
|
+
store,
|
|
199
|
+
platform: NATIVE,
|
|
200
|
+
sessionMode: 'identity',
|
|
201
|
+
identity: await makeBinding({ pin: PIN }),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(setTokens).not.toHaveBeenCalledWith(jwtFor('other-user'));
|
|
205
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'device-secret-mint' });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('plants a warm token in ACCOUNT mode regardless of which account it belongs to (unchanged)', async () => {
|
|
209
|
+
const store = await seedStore({
|
|
210
|
+
sessionId: 'sess-other',
|
|
211
|
+
userId: 'other-user',
|
|
212
|
+
accessToken: jwtFor('other-user'),
|
|
213
|
+
expiresAt: farFuture(),
|
|
214
|
+
});
|
|
215
|
+
const { oxy, setTokens } = makeOxy();
|
|
216
|
+
|
|
217
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
|
|
218
|
+
|
|
219
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'warm-token-plant' });
|
|
220
|
+
expect(setTokens).toHaveBeenCalledWith(jwtFor('other-user'));
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('runSessionColdBoot — identity mode: device-secret-mint', () => {
|
|
225
|
+
it('skips the mint entirely when there is no verified pin', async () => {
|
|
226
|
+
const store = await seedStore();
|
|
227
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
|
|
228
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
229
|
+
|
|
230
|
+
const outcome = await runSessionColdBoot({
|
|
231
|
+
oxy,
|
|
232
|
+
store,
|
|
233
|
+
platform: NATIVE,
|
|
234
|
+
sessionMode: 'identity',
|
|
235
|
+
identity: await makeBinding(), // no pin seeded
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// An unpinned mint would have resolved `other-user` — never acceptable here.
|
|
239
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
240
|
+
expect(outcome).toMatchObject({
|
|
241
|
+
kind: 'session',
|
|
242
|
+
via: 'identity-key-signin',
|
|
243
|
+
session: { userId: 'vault-user' },
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('keeps the device secret and re-establishes when the pinned account left the device', async () => {
|
|
248
|
+
const store = await seedStore();
|
|
249
|
+
const mintFromDeviceSecret = jest.fn(async () => {
|
|
250
|
+
throw Object.assign(new Error('account_not_on_device'), { status: 401 });
|
|
251
|
+
});
|
|
252
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
253
|
+
|
|
254
|
+
const outcome = await runSessionColdBoot({
|
|
255
|
+
oxy,
|
|
256
|
+
store,
|
|
257
|
+
platform: NATIVE,
|
|
258
|
+
sessionMode: 'identity',
|
|
259
|
+
identity: await makeBinding({ pin: PIN }),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
|
|
263
|
+
// The healthy credential was never dropped over a stale identity binding
|
|
264
|
+
// (the identity sign-in then overwrote it with the freshly minted one).
|
|
265
|
+
expect(await store.load()).toMatchObject({ deviceSecret: 'ds-identity' });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('clears a pin whose key no longer matches, then re-establishes from the current key', async () => {
|
|
269
|
+
const store = await seedStore();
|
|
270
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
|
|
271
|
+
const { oxy } = makeOxy({ mintFromDeviceSecret });
|
|
272
|
+
const pinStore = createMemoryIdentityPinStore();
|
|
273
|
+
await pinStore.save(PIN);
|
|
274
|
+
|
|
275
|
+
const outcome = await runSessionColdBoot({
|
|
276
|
+
oxy,
|
|
277
|
+
store,
|
|
278
|
+
platform: NATIVE,
|
|
279
|
+
sessionMode: 'identity',
|
|
280
|
+
identity: {
|
|
281
|
+
pinStore,
|
|
282
|
+
readPublicKey: async () => OTHER_PUBLIC_KEY,
|
|
283
|
+
signChallenge: signWith(OTHER_PUBLIC_KEY),
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
288
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
|
|
289
|
+
expect(await pinStore.load()).toEqual({ publicKey: OTHER_PUBLIC_KEY, accountId: 'vault-user' });
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('runSessionColdBoot — identity-key-signin vs shared-key-signin', () => {
|
|
294
|
+
it('identity mode runs identity-key-signin and NEVER the shared-keychain lane', async () => {
|
|
295
|
+
const store = createMemoryAuthStateStore(); // no mint credential
|
|
296
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
297
|
+
const requestChallenge = jest.fn(async () => ({
|
|
298
|
+
challenge: 'chal-1',
|
|
299
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
300
|
+
}));
|
|
301
|
+
const verifyChallenge = jest.fn(async () => IDENTITY_SESSION);
|
|
302
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity, requestChallenge, verifyChallenge });
|
|
303
|
+
const binding = await makeBinding();
|
|
304
|
+
const onSession = jest.fn();
|
|
305
|
+
|
|
306
|
+
const outcome = await runSessionColdBoot({
|
|
307
|
+
oxy,
|
|
308
|
+
store,
|
|
309
|
+
platform: NATIVE,
|
|
310
|
+
sessionMode: 'identity',
|
|
311
|
+
identity: binding,
|
|
312
|
+
onSession,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
316
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'identity-key-signin' });
|
|
317
|
+
expect(onSession).toHaveBeenCalledWith(
|
|
318
|
+
expect.objectContaining({
|
|
319
|
+
sessionId: 'sess-identity',
|
|
320
|
+
userId: 'vault-user',
|
|
321
|
+
via: 'identity-key-signin',
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
// Boot-path network calls stay single-attempt.
|
|
325
|
+
expect(requestChallenge).toHaveBeenCalledWith(PUBLIC_KEY, { retry: false });
|
|
326
|
+
expect(verifyChallenge).toHaveBeenCalledWith(
|
|
327
|
+
PUBLIC_KEY,
|
|
328
|
+
'chal-1',
|
|
329
|
+
'sig(chal-1)',
|
|
330
|
+
1_700_000_000_000,
|
|
331
|
+
undefined,
|
|
332
|
+
undefined,
|
|
333
|
+
{ retry: false },
|
|
334
|
+
);
|
|
335
|
+
// The pin and the fast-lane credential are both written for the next boot.
|
|
336
|
+
expect(await binding.pinStore.load()).toEqual(PIN);
|
|
337
|
+
expect(await store.load()).toMatchObject({ userId: 'vault-user', deviceSecret: 'ds-identity' });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('account mode runs shared-key-signin and NEVER the identity lane (unchanged)', async () => {
|
|
341
|
+
const store = createMemoryAuthStateStore();
|
|
342
|
+
const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
|
|
343
|
+
const requestChallenge = jest.fn(async () => ({ challenge: 'c', expiresAt: 'e' }));
|
|
344
|
+
const { oxy } = makeOxy({ signInWithSharedIdentity, requestChallenge });
|
|
345
|
+
|
|
346
|
+
const outcome = await runSessionColdBoot({ oxy, store, platform: NATIVE });
|
|
347
|
+
|
|
348
|
+
expect(outcome).toMatchObject({ kind: 'session', via: 'shared-key-signin' });
|
|
349
|
+
expect(signInWithSharedIdentity).toHaveBeenCalledWith({ requestOptions: { retry: false } });
|
|
350
|
+
expect(requestChallenge).not.toHaveBeenCalled();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('is offline-gated like every other network step', async () => {
|
|
354
|
+
const store = createMemoryAuthStateStore();
|
|
355
|
+
const requestChallenge = jest.fn(async () => ({ challenge: 'c', expiresAt: 'e' }));
|
|
356
|
+
const { oxy } = makeOxy({ requestChallenge });
|
|
357
|
+
const onSignedOut = jest.fn();
|
|
358
|
+
|
|
359
|
+
const outcome = await runSessionColdBoot({
|
|
360
|
+
oxy,
|
|
361
|
+
store,
|
|
362
|
+
platform: NATIVE,
|
|
363
|
+
sessionMode: 'identity',
|
|
364
|
+
identity: await makeBinding(),
|
|
365
|
+
isOffline: () => true,
|
|
366
|
+
onSignedOut,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
expect(requestChallenge).not.toHaveBeenCalled();
|
|
370
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
371
|
+
expect(onSignedOut).toHaveBeenCalledWith('no_session');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('ends signed out (never account-mode) when identity mode is requested without a binding', async () => {
|
|
375
|
+
const store = await seedStore({ accessToken: jwtFor('other-user'), expiresAt: farFuture() });
|
|
376
|
+
const mintFromDeviceSecret = jest.fn(async () => MINT_WHILE_SWITCHED);
|
|
377
|
+
const { oxy, setTokens } = makeOxy({ mintFromDeviceSecret });
|
|
378
|
+
const onSignedOut = jest.fn();
|
|
379
|
+
|
|
380
|
+
const outcome = await runSessionColdBoot({
|
|
381
|
+
oxy,
|
|
382
|
+
store,
|
|
383
|
+
platform: NATIVE,
|
|
384
|
+
sessionMode: 'identity',
|
|
385
|
+
onSignedOut,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
389
|
+
expect(onSignedOut).toHaveBeenCalledWith('error');
|
|
390
|
+
expect(setTokens).not.toHaveBeenCalled();
|
|
391
|
+
expect(mintFromDeviceSecret).not.toHaveBeenCalled();
|
|
392
|
+
});
|
|
393
|
+
});
|