@oxyhq/core 8.0.0 → 9.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/coldBootV2.js +66 -281
- package/dist/cjs/crypto/keyManager.js +0 -95
- package/dist/cjs/index.js +6 -19
- package/dist/cjs/mixins/OxyServices.auth.js +4 -7
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +24 -92
- package/dist/cjs/mixins/index.js +2 -3
- package/dist/cjs/session/SessionClient.js +29 -100
- package/dist/cjs/session/accountDialogController.js +0 -13
- package/dist/cjs/session/authStateStore.js +14 -88
- package/dist/cjs/session/createSessionClient.js +2 -9
- package/dist/cjs/session/refresh.js +46 -62
- package/dist/cjs/utils/registrableApex.js +2 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +67 -279
- package/dist/esm/crypto/keyManager.js +0 -95
- package/dist/esm/index.js +7 -11
- package/dist/esm/mixins/OxyServices.auth.js +4 -7
- package/dist/esm/mixins/OxyServices.deviceBoot.js +25 -93
- package/dist/esm/mixins/index.js +2 -3
- package/dist/esm/session/SessionClient.js +29 -100
- package/dist/esm/session/accountDialogController.js +0 -13
- package/dist/esm/session/authStateStore.js +13 -87
- package/dist/esm/session/createSessionClient.js +2 -9
- package/dist/esm/session/refresh.js +46 -62
- package/dist/esm/utils/registrableApex.js +2 -6
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -3
- package/dist/types/boot/coldBootV2.d.ts +28 -53
- package/dist/types/crypto/keyManager.d.ts +0 -21
- package/dist/types/index.d.ts +3 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +22 -46
- package/dist/types/session/SessionClient.d.ts +4 -38
- package/dist/types/session/accountDialogController.d.ts +1 -3
- package/dist/types/session/authStateStore.d.ts +38 -43
- package/dist/types/session/createSessionClient.d.ts +2 -9
- package/dist/types/session/refresh.d.ts +32 -28
- package/dist/types/utils/registrableApex.d.ts +2 -6
- package/package.json +2 -2
- package/src/HttpService.ts +3 -3
- package/src/boot/__tests__/coldBootV2.test.ts +237 -236
- package/src/boot/coldBootV2.ts +92 -333
- package/src/crypto/keyManager.ts +0 -101
- package/src/index.ts +7 -30
- package/src/mixins/OxyServices.auth.ts +5 -9
- package/src/mixins/OxyServices.deviceBoot.ts +30 -115
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +36 -80
- package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
- package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
- package/src/mixins/index.ts +2 -3
- package/src/session/SessionClient.ts +29 -120
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
- package/src/session/__tests__/authStateStore.test.ts +47 -33
- package/src/session/__tests__/refresh.test.ts +72 -44
- package/src/session/accountDialogController.ts +4 -18
- package/src/session/authStateStore.ts +43 -111
- package/src/session/createSessionClient.ts +2 -9
- package/src/session/refresh.ts +60 -83
- package/src/utils/registrableApex.ts +2 -6
- package/dist/cjs/boot/deviceBootReturn.js +0 -152
- package/dist/esm/boot/deviceBootReturn.js +0 -146
- package/dist/types/boot/deviceBootReturn.d.ts +0 -83
- package/src/boot/__tests__/deviceBootReturn.test.ts +0 -158
- package/src/boot/deviceBootReturn.ts +0 -195
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
- package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
|
@@ -30,13 +30,13 @@ export interface RequestOptions {
|
|
|
30
30
|
* Skip BOTH the bearer auth header (and its near-expiry preflight refresh)
|
|
31
31
|
* AND the 401-driven auto-refresh/retry for this request.
|
|
32
32
|
*
|
|
33
|
-
* Required for the body-authenticated
|
|
34
|
-
* /
|
|
33
|
+
* Required for the body-authenticated device-secret mint (`POST
|
|
34
|
+
* /session/device/token`): it does not need a bearer, and — critically — it is
|
|
35
35
|
* itself invoked from inside the registered `AuthRefreshHandler`. If it went
|
|
36
36
|
* through the normal preflight, `getAuthHeader` would call
|
|
37
37
|
* `refreshAccessToken` while the handler-owning `tokenRefreshPromise` is
|
|
38
38
|
* still in flight and await ITSELF (deadlock). Skipping auth makes the
|
|
39
|
-
*
|
|
39
|
+
* mint call fully independent of the current (near-expired) bearer.
|
|
40
40
|
*/
|
|
41
41
|
skipAuth?: boolean;
|
|
42
42
|
}
|
|
@@ -1,36 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* coldBootV2 — one device-first cold boot for every consumer.
|
|
3
|
+
*
|
|
4
|
+
* On a fresh page load / app launch this resolves the device's session in a
|
|
5
|
+
* deterministic order, built on the pure `runColdBoot` primitive. It NEVER
|
|
6
|
+
* redirects to a login page: an unresolved boot ends in a signed-out state that
|
|
7
|
+
* the app renders with a "Sign in with Oxy" button.
|
|
8
|
+
*
|
|
9
|
+
* Ordered steps (first to yield a session wins):
|
|
10
|
+
* 1. `device-secret-mint` (web + native) — the zero-cookie transport: when the
|
|
11
|
+
* origin persisted a `deviceId` + `deviceSecret`, mint a short access token
|
|
12
|
+
* with a single bearer-less POST to `/session/device/token` (no cookie, no
|
|
13
|
+
* navigation) and rotate the secret in-use.
|
|
14
|
+
* 2. `shared-key-signin` (native) — re-mint from the shared-keychain identity.
|
|
15
|
+
* 3. Signed out.
|
|
16
|
+
*
|
|
17
|
+
* ESM-safe (no `require()`); no react/react-native/expo imports.
|
|
18
|
+
*/
|
|
1
19
|
import { type ColdBootOutcome } from '../utils/coldBoot';
|
|
2
20
|
import type { OxyServices } from '../OxyServices';
|
|
3
21
|
import type { AuthStateStore } from '../session/authStateStore';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
* bounced again; they sign in explicitly.
|
|
10
|
-
*/
|
|
11
|
-
export declare const BOOT_ATTEMPTED_KEY = "oxy.boot.attempted";
|
|
12
|
-
/** Why a cold boot ended without a session. */
|
|
13
|
-
export type SignedOutReason = 'no_session' | 'new_device' | 'state-mismatch' | 'error';
|
|
14
|
-
/**
|
|
15
|
-
* The DOM/storage seam. All access is injected so the boot is unit-testable
|
|
16
|
-
* under the jest `node` environment; {@link createBrowserColdBootDom} provides
|
|
17
|
-
* the guarded real-globals implementation used in production.
|
|
18
|
-
*/
|
|
19
|
-
export interface ColdBootDom {
|
|
20
|
-
getHash(): string;
|
|
21
|
-
stripFragment(): void;
|
|
22
|
-
getSessionItem(key: string): string | null;
|
|
23
|
-
setSessionItem(key: string, value: string): void;
|
|
24
|
-
removeSessionItem(key: string): void;
|
|
25
|
-
getLocalItem(key: string): string | null;
|
|
26
|
-
setLocalItem(key: string, value: string): void;
|
|
27
|
-
getLocationHostname(): string | null;
|
|
28
|
-
/** Current href WITHOUT its hash fragment (the bootstrap `return_to`). */
|
|
29
|
-
getReturnToHref(): string | null;
|
|
30
|
-
navigate(url: string): void;
|
|
31
|
-
/** A fresh high-entropy CSRF state token. */
|
|
32
|
-
randomState(): string;
|
|
22
|
+
/** The winning session shape a cold-boot step reports. */
|
|
23
|
+
export interface DeviceBootSession {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
userId: string;
|
|
26
|
+
accessToken: string;
|
|
33
27
|
}
|
|
28
|
+
/** Why a cold boot ended without a session. */
|
|
29
|
+
export type SignedOutReason = 'no_session' | 'error';
|
|
34
30
|
export interface RunSessionColdBootOptions {
|
|
35
31
|
oxy: OxyServices;
|
|
36
32
|
store: AuthStateStore;
|
|
@@ -39,38 +35,17 @@ export interface RunSessionColdBootOptions {
|
|
|
39
35
|
isWeb?: boolean;
|
|
40
36
|
isNative?: boolean;
|
|
41
37
|
};
|
|
42
|
-
/**
|
|
43
|
-
* The RP return URL for the cross-apex hop. Defaults to the current href
|
|
44
|
-
* (sans fragment) via the DOM seam.
|
|
45
|
-
*/
|
|
46
|
-
returnTo?: string;
|
|
47
38
|
/** Invoked with the winning session (token already planted). */
|
|
48
39
|
onSession?: (session: DeviceBootSession & {
|
|
49
40
|
via: string;
|
|
50
41
|
}) => void | Promise<void>;
|
|
51
|
-
/** Invoked when the boot ended signed out
|
|
42
|
+
/** Invoked when the boot ended signed out. */
|
|
52
43
|
onSignedOut?: (reason: SignedOutReason) => void | Promise<void>;
|
|
53
44
|
onStepError?: (id: string, error: unknown) => void;
|
|
54
|
-
/** DOM/storage seam; defaults to the guarded browser implementation. */
|
|
55
|
-
dom?: ColdBootDom;
|
|
56
45
|
}
|
|
57
|
-
/**
|
|
58
|
-
* The guarded real-globals {@link ColdBootDom}. Every accessor tolerates a
|
|
59
|
-
* missing/throwing global (SSR, sandboxed iframe) by returning a neutral value
|
|
60
|
-
* or no-op, so the boot degrades to signed-out rather than crashing.
|
|
61
|
-
*/
|
|
62
|
-
export declare function createBrowserColdBootDom(): ColdBootDom;
|
|
63
|
-
/**
|
|
64
|
-
* True when both hosts are same-site / same-apex. For a normal multi-label host
|
|
65
|
-
* pair, that means sharing a registrable domain. For an IP literal or a
|
|
66
|
-
* single-label host (no registrable domain), same-apex requires the two hosts
|
|
67
|
-
* to be EXACTLY equal — never grouped by a spurious trailing-label match.
|
|
68
|
-
*/
|
|
69
|
-
export declare function isSameApex(pageHost: string, apiHost: string): boolean;
|
|
70
46
|
/**
|
|
71
47
|
* Run the device-first cold boot. Resolves to the `runColdBoot` outcome and, as
|
|
72
|
-
* a side effect, invokes `onSession` (winning session, token already planted)
|
|
73
|
-
*
|
|
74
|
-
* cross-apex hop, in which case neither fires).
|
|
48
|
+
* a side effect, invokes `onSession` (winning session, token already planted) or
|
|
49
|
+
* `onSignedOut` (no session).
|
|
75
50
|
*/
|
|
76
51
|
export declare function runSessionColdBoot(opts: RunSessionColdBootOptions): Promise<ColdBootOutcome<DeviceBootSession>>;
|
|
@@ -157,27 +157,6 @@ export declare class KeyManager {
|
|
|
157
157
|
* @returns True if migration was successful, false if no local identity exists
|
|
158
158
|
*/
|
|
159
159
|
static migrateToSharedIdentity(): Promise<boolean>;
|
|
160
|
-
/**
|
|
161
|
-
* Store the opaque device-attribution token in the SHARED keychain so every
|
|
162
|
-
* Oxy app on this device reads the SAME token and therefore resolves to one
|
|
163
|
-
* server-side DeviceSession.
|
|
164
|
-
*
|
|
165
|
-
* Mirrors the shared-session storage options exactly (iOS keychain access
|
|
166
|
-
* group `group.so.oxy.shared`; Android shared secure store). Native-only —
|
|
167
|
-
* a no-op on web (the shared keychain does not exist there; web persists its
|
|
168
|
-
* deviceToken in the per-origin {@link AuthStateStore} instead).
|
|
169
|
-
*/
|
|
170
|
-
static setSharedDeviceToken(deviceToken: string): Promise<void>;
|
|
171
|
-
/**
|
|
172
|
-
* Read the shared device-attribution token, or `null` when none is set (or
|
|
173
|
-
* on web). Mirrors {@link getSharedSession}'s keychain-access-group read.
|
|
174
|
-
*/
|
|
175
|
-
static getSharedDeviceToken(): Promise<string | null>;
|
|
176
|
-
/**
|
|
177
|
-
* Delete the shared device-attribution token (device signout-all). Best-effort:
|
|
178
|
-
* a keychain failure is logged, not thrown, so it cannot block a sign-out.
|
|
179
|
-
*/
|
|
180
|
-
static clearSharedDeviceToken(): Promise<void>;
|
|
181
160
|
/**
|
|
182
161
|
* Atomically persist a key pair to secure storage with verification + backup.
|
|
183
162
|
*
|
package/dist/types/index.d.ts
CHANGED
|
@@ -95,14 +95,12 @@ export { projectSwitchableAccounts, switchableAccountIds, } from './session/acco
|
|
|
95
95
|
export type { SwitchableAccount, SwitchableAccountUser, ProjectSwitchableAccountsInput, } from './session/accountProjection';
|
|
96
96
|
export { AccountDialogController, createAccountDialogController, } from './session/accountDialogController';
|
|
97
97
|
export type { AccountDialogControllerOptions, AccountDialogSnapshot, AccountDialogView, SignInFlowPhase, SignInFlowState, } from './session/accountDialogController';
|
|
98
|
-
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY,
|
|
98
|
+
export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore';
|
|
99
99
|
export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
|
|
100
100
|
export { refreshPersistedSession, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh';
|
|
101
101
|
export type { RefreshDeps, TokenRefreshSchedulerHandle } from './session/refresh';
|
|
102
|
-
export { runSessionColdBoot
|
|
103
|
-
export type { RunSessionColdBootOptions,
|
|
104
|
-
export { consumeDeviceBootReturn, parseDeviceBootFragment, hashHasBootFragment, BOOT_FRAGMENT_PARAM, BOOT_STATE_SESSION_KEY, } from './boot/deviceBootReturn';
|
|
105
|
-
export type { DeviceBootSession, DeviceBootReturnOutcome, ConsumeDeviceBootReturnDeps, } from './boot/deviceBootReturn';
|
|
102
|
+
export { runSessionColdBoot } from './boot/coldBootV2';
|
|
103
|
+
export type { RunSessionColdBootOptions, SignedOutReason, DeviceBootSession, } from './boot/coldBootV2';
|
|
106
104
|
export { packageInfo } from './constants/version';
|
|
107
105
|
import { OxyServices } from './OxyServices';
|
|
108
106
|
export default OxyServices;
|
|
@@ -469,14 +469,12 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
469
469
|
* (`{ twoFactorRequired, loginToken }`) to complete via
|
|
470
470
|
* {@link completeTwoFactorSignIn}, or a session arm.
|
|
471
471
|
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
* caller has an authenticated client without a second round-trip.
|
|
472
|
+
* On the session arm, a returned access token is planted immediately
|
|
473
|
+
* (mirroring {@link verifyChallenge}), so the caller has an authenticated
|
|
474
|
+
* client without a second round-trip. The response's `deviceId` +
|
|
475
|
+
* `deviceSecret` are the zero-cookie restore credential the caller persists.
|
|
477
476
|
*/
|
|
478
477
|
passwordSignIn(identifier: string, password: string, options?: {
|
|
479
|
-
deviceToken?: string;
|
|
480
478
|
deviceName?: string;
|
|
481
479
|
deviceFingerprint?: string;
|
|
482
480
|
}): Promise<LoginResult>;
|
|
@@ -491,7 +489,6 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
491
489
|
loginToken: string;
|
|
492
490
|
token?: string;
|
|
493
491
|
backupCode?: string;
|
|
494
|
-
deviceToken?: string;
|
|
495
492
|
deviceName?: string;
|
|
496
493
|
}): Promise<LoginSessionResult>;
|
|
497
494
|
httpService: import("../HttpService").HttpService;
|
|
@@ -1,62 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Device-first
|
|
2
|
+
* Device-first token mint mixin.
|
|
3
3
|
*
|
|
4
|
-
* The client half of the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The client half of the zero-cookie device transport: the single network call
|
|
5
|
+
* the cold boot (`coldBootV2`) and the unified re-mint handler (`refresh.ts`)
|
|
6
|
+
* make to turn a first-party `deviceId` + `deviceSecret` into a fresh access
|
|
7
|
+
* token. The response is validated against the `@oxyhq/contracts`
|
|
8
|
+
* `deviceTokenMintResponseSchema`, so producer (oxy-api) and consumer cannot
|
|
9
9
|
* drift — an unexpected shape throws here rather than silently corrupting the
|
|
10
10
|
* persisted store.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* and `setTokens`, so the same network primitive can be reused from either
|
|
16
|
-
* without double-planting.
|
|
12
|
+
* This method carries NO persistence or token-planting side effects of its own;
|
|
13
|
+
* the cold boot / re-mint handler own persistence and `setTokens`, so the same
|
|
14
|
+
* primitive can be reused from either without double-planting.
|
|
17
15
|
*/
|
|
18
|
-
import { type
|
|
16
|
+
import { type DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
19
17
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
20
18
|
export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
21
19
|
new (...args: any[]): {
|
|
22
20
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* Zero-cookie mint. Present the first-party `deviceId` + `deviceSecret` to
|
|
22
|
+
* `POST /session/device/token` — NO bearer, NO cookies: possession of the
|
|
23
|
+
* secret IS the device-ownership proof. Returns a fresh short access token
|
|
24
|
+
* for the device's active account plus `nextDeviceSecret` (rotation-in-use)
|
|
25
|
+
* and the projected device-session `state`.
|
|
25
26
|
*
|
|
26
|
-
*
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
*
|
|
31
|
-
* cookie via a credentialed same-origin fetch and either resolves a full
|
|
32
|
-
* session bundle or reports the device is known-but-signed-out. Never
|
|
33
|
-
* redirects.
|
|
34
|
-
*
|
|
35
|
-
* @throws if the response matches neither arm of {@link webSessionResultSchema}.
|
|
36
|
-
*/
|
|
37
|
-
requestWebSession(): Promise<WebSessionResult>;
|
|
38
|
-
/**
|
|
39
|
-
* Rotate the persisted refresh-token family (web + native, one call). The
|
|
40
|
-
* caller (refresh handler / cold boot) plants + persists the rotated pair.
|
|
27
|
+
* `skipAuth`: this call carries no bearer, so a 401 must surface DIRECTLY —
|
|
28
|
+
* never trigger `HttpService`'s 401→refresh→retry dance. The cold boot / re-
|
|
29
|
+
* mint handler read the 401 body (`invalid_device_secret` vs
|
|
30
|
+
* `no_active_session`) to decide whether to drop the secret and fall back or
|
|
31
|
+
* resolve signed-out.
|
|
41
32
|
*
|
|
42
|
-
* @throws if the response does not match {@link
|
|
43
|
-
*/
|
|
44
|
-
refreshWithToken(refreshToken: string): Promise<TokenRefreshResponse>;
|
|
45
|
-
/**
|
|
46
|
-
* Issue (or rotate) the native channel's opaque device token. Bearer-gated —
|
|
47
|
-
* the server derives the deviceId from the JWT. Native apps mirror the
|
|
48
|
-
* returned token into the shared keychain via
|
|
49
|
-
* `KeyManager.setSharedDeviceToken`.
|
|
50
|
-
*
|
|
51
|
-
* @throws if the response does not match {@link deviceTokenIssueResponseSchema}.
|
|
52
|
-
*/
|
|
53
|
-
issueNativeDeviceToken(): Promise<string>;
|
|
54
|
-
/**
|
|
55
|
-
* Build the top-level `GET /auth/device/bootstrap` URL for the cross-apex
|
|
56
|
-
* hop. The server validates `return_to` against the trusted-origin lane and
|
|
57
|
-
* echoes `state` back in the return fragment for CSRF verification.
|
|
33
|
+
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
58
34
|
*/
|
|
59
|
-
|
|
35
|
+
mintFromDeviceSecret(deviceId: string, deviceSecret: string): Promise<DeviceTokenMintResponse>;
|
|
60
36
|
httpService: import("../HttpService").HttpService;
|
|
61
37
|
cloudURL: string;
|
|
62
38
|
config: import("../OxyServices.base").OxyConfig;
|
|
@@ -38,30 +38,6 @@ export interface SessionClientOptions {
|
|
|
38
38
|
* absent it falls back to `getSocketIO()`.
|
|
39
39
|
*/
|
|
40
40
|
socketFactory?: SocketIOFactory;
|
|
41
|
-
/**
|
|
42
|
-
* Gate + credential for opening the realtime socket while SIGNED OUT (no
|
|
43
|
-
* access token), so an idle tab still joins its `device:<id>` room and can
|
|
44
|
-
* self-acquire the moment a sibling app/tab signs in on the same device.
|
|
45
|
-
* Returns:
|
|
46
|
-
* - `true` → connect and rely on the first-party `oxy_device` cookie
|
|
47
|
-
* riding the same-site handshake (web `*.oxy.so`; the cookie is HttpOnly
|
|
48
|
-
* so JS cannot read it, but the browser sends it automatically).
|
|
49
|
-
* - a string → connect and present it as `deviceToken` in the handshake
|
|
50
|
-
* auth (native shared-keychain device token; RN has no cookie jar).
|
|
51
|
-
* - `false`/`null` → do NOT open a signed-out socket (the default — e.g. a
|
|
52
|
-
* native app with no known device yet).
|
|
53
|
-
* Called at connect time (and each reconnect); may be async (keychain read).
|
|
54
|
-
*/
|
|
55
|
-
signedOutSocketAuth?: () => boolean | string | null | Promise<boolean | string | null>;
|
|
56
|
-
/**
|
|
57
|
-
* Invoked when a `session_state` push (or a same-origin BroadcastChannel wake)
|
|
58
|
-
* arrives while this tab is SIGNED OUT and the pushed device state has at
|
|
59
|
-
* least one account — i.e. a sibling just signed in on this device. The
|
|
60
|
-
* consumer runs its session acquisition (cold boot / `requestWebSession`),
|
|
61
|
-
* which plants a token and flips the tab to signed-in. Guarded + idempotent:
|
|
62
|
-
* only one acquisition runs at a time, and a returned promise gates the next.
|
|
63
|
-
*/
|
|
64
|
-
onSessionAppeared?: () => void | Promise<void>;
|
|
65
41
|
}
|
|
66
42
|
type StateListener = (state: DeviceSessionState | null) => void;
|
|
67
43
|
export declare class SessionClient {
|
|
@@ -72,11 +48,7 @@ export declare class SessionClient {
|
|
|
72
48
|
protected socket: MinimalSocket | null;
|
|
73
49
|
private tokenUnsub;
|
|
74
50
|
private started;
|
|
75
|
-
/**
|
|
76
|
-
private acquiring;
|
|
77
|
-
/** True while the live socket is an anonymous (signed-out) device connection. */
|
|
78
|
-
private socketAnonymous;
|
|
79
|
-
/** Same-origin cross-tab wake channel; null on platforms without BroadcastChannel. */
|
|
51
|
+
/** Same-origin cross-tab state-propagation channel; null on platforms without BroadcastChannel. */
|
|
80
52
|
private channel;
|
|
81
53
|
constructor(host: SessionClientHost, options?: SessionClientOptions);
|
|
82
54
|
getState(): DeviceSessionState | null;
|
|
@@ -118,17 +90,11 @@ export declare class SessionClient {
|
|
|
118
90
|
start(): Promise<void>;
|
|
119
91
|
stop(): void;
|
|
120
92
|
private connectSocket;
|
|
121
|
-
/**
|
|
122
|
-
* Run the consumer's session acquisition at most once at a time. A returned
|
|
123
|
-
* promise gates the next attempt (reset on settle), so a failed acquisition
|
|
124
|
-
* can retry on the NEXT push while a burst of identical pushes cannot pile up.
|
|
125
|
-
*/
|
|
126
|
-
private requestAcquisition;
|
|
127
93
|
/**
|
|
128
94
|
* Open the same-origin `BroadcastChannel` (web only). A sibling tab that
|
|
129
|
-
* commits
|
|
130
|
-
*
|
|
131
|
-
*
|
|
95
|
+
* commits an account switch / sign-out posts a wake ping; on receipt an
|
|
96
|
+
* authenticated tab re-syncs its device state — instant + network-free for the
|
|
97
|
+
* common "two tabs of the same origin" case, with no state (and no tokens)
|
|
132
98
|
* ever crossing the channel. No-op on native (no BroadcastChannel).
|
|
133
99
|
*/
|
|
134
100
|
private openBroadcastChannel;
|
|
@@ -92,9 +92,7 @@ export interface AccountDialogControllerOptions {
|
|
|
92
92
|
* falls back to `SessionClient.registerAndActivate` (registration + activation
|
|
93
93
|
* only — no provider-side durable persist/hydration).
|
|
94
94
|
*/
|
|
95
|
-
commitSession?: (session: SessionLoginResponse
|
|
96
|
-
refreshToken?: string;
|
|
97
|
-
}) => Promise<void>;
|
|
95
|
+
commitSession?: (session: SessionLoginResponse) => Promise<void>;
|
|
98
96
|
/** Notified after a completed sign-in (bearer planted + session committed). */
|
|
99
97
|
onSignedIn?: (user: MinimalUserData) => void;
|
|
100
98
|
/** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Persisted auth state — ONE shape, web + native.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `load / save / clear` interface plus
|
|
8
|
-
*
|
|
9
|
-
* platform storage API directly.
|
|
4
|
+
* The zero-cookie device transport persists the device credential per ORIGIN so
|
|
5
|
+
* a reload restores the session locally without a redirect: `deviceId` +
|
|
6
|
+
* `deviceSecret` mint a fresh access token via `POST /session/device/token`.
|
|
7
|
+
* This module is the storage seam: a tiny `load / save / clear` interface plus
|
|
8
|
+
* platform factories, so the cold boot (`coldBootV2`) and the unified re-mint
|
|
9
|
+
* handler (`refresh.ts`) never touch a platform storage API directly.
|
|
10
10
|
*
|
|
11
11
|
* Platform-agnostic — the native factory takes an INJECTED key/value store
|
|
12
12
|
* (`@oxyhq/services` passes a SecureStore-backed adapter) so `@oxyhq/core`
|
|
@@ -19,52 +19,53 @@
|
|
|
19
19
|
/**
|
|
20
20
|
* The persisted session credential set for a single origin.
|
|
21
21
|
*
|
|
22
|
-
* `
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* `sessionId` + `userId` identify the owning device session and active account.
|
|
23
|
+
* `deviceId` + `deviceSecret` are the zero-cookie mint credential: the client
|
|
24
|
+
* presents BOTH at `POST /session/device/token` — the secret is the proof, the
|
|
25
|
+
* deviceId selects the device doc. The secret is rotated in-use: the mint returns
|
|
26
|
+
* `nextDeviceSecret`, which the cold boot / re-mint handler persist BEFORE
|
|
27
|
+
* planting the minted access token (multi-tab anti-loss).
|
|
26
28
|
*
|
|
27
|
-
* `accessToken` + `expiresAt` are OPTIONAL warm-boot fields. Persisting them
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* refresh token is the dominant secret either way).
|
|
29
|
+
* `accessToken` + `expiresAt` are OPTIONAL warm-boot fields. Persisting them lets
|
|
30
|
+
* the cold boot plant a still-valid access token on the very first paint WITHOUT
|
|
31
|
+
* a blocking mint round-trip — the proactive scheduler then rotates it in the
|
|
32
|
+
* background. They are a strict optimization: the store is fully functional (via
|
|
33
|
+
* `deviceSecret`) when they are absent or stale, and the access token is
|
|
34
|
+
* short-lived, so persisting it adds no exposure the already-persisted
|
|
35
|
+
* `deviceSecret` does not.
|
|
35
36
|
*/
|
|
36
37
|
export interface PersistedAuthState {
|
|
37
38
|
sessionId: string;
|
|
38
|
-
refreshToken: string;
|
|
39
39
|
userId: string;
|
|
40
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The stable device identifier this session is bound to (zero-cookie
|
|
42
|
+
* transport). Persisted alongside {@link deviceSecret} because the
|
|
43
|
+
* `POST /session/device/token` mint presents BOTH. Sourced from every login
|
|
44
|
+
* lane (password / 2FA / QR claim / challenge verify) via the response's
|
|
45
|
+
* `deviceId`.
|
|
46
|
+
*/
|
|
47
|
+
deviceId?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The rotating device secret (zero-cookie transport). Possession of it mints a
|
|
50
|
+
* short access token for the device's active account via
|
|
51
|
+
* `POST /session/device/token`. Rotated in-use.
|
|
52
|
+
*/
|
|
53
|
+
deviceSecret?: string;
|
|
41
54
|
/** Optional warm-boot access token (short-lived; see interface docs). */
|
|
42
55
|
accessToken?: string;
|
|
43
56
|
/** Optional warm-boot access-token expiry, ISO-8601. */
|
|
44
57
|
expiresAt?: string;
|
|
45
58
|
}
|
|
46
59
|
/**
|
|
47
|
-
* The storage seam consumed by the cold boot and the
|
|
60
|
+
* The storage seam consumed by the cold boot and the re-mint handler. Async
|
|
48
61
|
* throughout so one interface fits both synchronous web `localStorage` and
|
|
49
|
-
* asynchronous native SecureStore/AsyncStorage.
|
|
50
|
-
*
|
|
51
|
-
* Two lifetimes:
|
|
52
|
-
* - The SESSION credential blob (`load`/`save`/`clear`) is per-sign-in and is
|
|
53
|
-
* wiped on `clear()` (sign-out).
|
|
54
|
-
* - The DEVICE token (`loadDeviceToken`/`saveDeviceToken`/`clearDeviceToken`)
|
|
55
|
-
* is long-lived device attribution that SURVIVES `clear()`: a signed-out
|
|
56
|
-
* browser is still the same device, and a later in-app (cross-apex,
|
|
57
|
-
* cookie-less) login sends this token so the new session joins the SAME
|
|
58
|
-
* server-side DeviceSession. Only an explicit device signout-all
|
|
59
|
-
* (`clearDeviceToken`) removes it.
|
|
62
|
+
* asynchronous native SecureStore/AsyncStorage. `clear()` (sign-out) wipes the
|
|
63
|
+
* per-sign-in credential blob.
|
|
60
64
|
*/
|
|
61
65
|
export interface AuthStateStore {
|
|
62
66
|
load(): Promise<PersistedAuthState | null>;
|
|
63
67
|
save(state: PersistedAuthState): Promise<void>;
|
|
64
68
|
clear(): Promise<void>;
|
|
65
|
-
loadDeviceToken(): Promise<string | null>;
|
|
66
|
-
saveDeviceToken(token: string): Promise<void>;
|
|
67
|
-
clearDeviceToken(): Promise<void>;
|
|
68
69
|
}
|
|
69
70
|
/**
|
|
70
71
|
* The minimal async key/value surface a native store must provide. Matches
|
|
@@ -81,12 +82,6 @@ export interface NativeKeyValueStorage {
|
|
|
81
82
|
* `oxy_shared_*` keychain keys in `KeyManager`, so it never collides.
|
|
82
83
|
*/
|
|
83
84
|
export declare const AUTH_STATE_STORAGE_KEY = "oxy.auth.v1";
|
|
84
|
-
/**
|
|
85
|
-
* Storage key for the long-lived device-attribution token. Separate from
|
|
86
|
-
* {@link AUTH_STATE_STORAGE_KEY} because it must OUTLIVE a session `clear()`
|
|
87
|
-
* (sign-out) — the device is unchanged across sign-ins.
|
|
88
|
-
*/
|
|
89
|
-
export declare const DEVICE_TOKEN_STORAGE_KEY = "oxy.device.v1";
|
|
90
85
|
/**
|
|
91
86
|
* A process-lifetime, in-memory {@link AuthStateStore}. Used directly for
|
|
92
87
|
* tests/SSR and as the degraded fallback of the web store when `localStorage`
|
|
@@ -104,8 +99,8 @@ export declare function createMemoryAuthStateStore(): AuthStateStore;
|
|
|
104
99
|
* for this page's lifetime — never throws on construction.
|
|
105
100
|
* - Individual `getItem`/`setItem`/`removeItem` are each wrapped: a read that
|
|
106
101
|
* throws yields `null`; a write that throws (quota, private mode) is
|
|
107
|
-
* swallowed. The
|
|
108
|
-
*
|
|
102
|
+
* swallowed. The re-mint lane treats a failed persist as "no durable state"
|
|
103
|
+
* rather than crashing.
|
|
109
104
|
*/
|
|
110
105
|
export declare function createWebAuthStateStore(): AuthStateStore;
|
|
111
106
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OxyServices } from '../OxyServices';
|
|
2
|
-
import { SessionClient, type
|
|
2
|
+
import { SessionClient, type TokenTransport } from './SessionClient';
|
|
3
3
|
import type { SocketIOFactory } from './socketLoader';
|
|
4
4
|
import { createSessionClientHost } from './sessionClientHost';
|
|
5
5
|
/**
|
|
@@ -24,14 +24,7 @@ import { createSessionClientHost } from './sessionClientHost';
|
|
|
24
24
|
* specifier (bundler-fragile in Metro/Expo-web and Vite against the published
|
|
25
25
|
* dist). When omitted, the client falls back to the lazy loader.
|
|
26
26
|
*/
|
|
27
|
-
export declare function createSessionClient(oxyServices: OxyServices, transport: TokenTransport, socketFactory?: SocketIOFactory
|
|
28
|
-
/**
|
|
29
|
-
* Optional signed-out realtime wiring: `signedOutSocketAuth` (open the socket
|
|
30
|
-
* while signed out so an idle tab receives its device pushes) and
|
|
31
|
-
* `onSessionAppeared` (self-acquire when a sibling signs in). See
|
|
32
|
-
* {@link SessionClientOptions}.
|
|
33
|
-
*/
|
|
34
|
-
extra?: Pick<SessionClientOptions, 'signedOutSocketAuth' | 'onSessionAppeared'>): {
|
|
27
|
+
export declare function createSessionClient(oxyServices: OxyServices, transport: TokenTransport, socketFactory?: SocketIOFactory): {
|
|
35
28
|
client: SessionClient;
|
|
36
29
|
host: ReturnType<typeof createSessionClientHost>;
|
|
37
30
|
};
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unified token refresh — THE single
|
|
2
|
+
* Unified token refresh — THE single access-token re-mint for web + native.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
4
|
+
* The access token is short-lived; there is no refresh token. To keep a session
|
|
5
|
+
* alive past the access token's TTL the client re-mints via the zero-cookie
|
|
6
|
+
* device transport:
|
|
6
7
|
*
|
|
7
|
-
* - `refreshPersistedSession` — arm 1
|
|
8
|
-
* (`POST /
|
|
9
|
-
* (native only) re-mints via
|
|
10
|
-
*
|
|
11
|
-
* `AuthRefreshHandler` installed on `HttpService`)
|
|
12
|
-
*
|
|
8
|
+
* - `refreshPersistedSession` — arm 1 mints a fresh access token from the
|
|
9
|
+
* persisted `deviceId` + `deviceSecret` (`POST /session/device/token`),
|
|
10
|
+
* planting + persisting the rotated secret; arm 2 (native only) re-mints via
|
|
11
|
+
* the shared-keychain identity when there is no usable secret. It is used BOTH
|
|
12
|
+
* reactively (wrapped as the `AuthRefreshHandler` installed on `HttpService`)
|
|
13
|
+
* AND proactively (the scheduler below calls it).
|
|
13
14
|
* - `createAuthRefreshHandler` / `installAuthRefreshHandler` wire arm 1+2 into
|
|
14
15
|
* `HttpService.setAuthRefreshHandler`, keeping that layer's single-flight
|
|
15
16
|
* dedup + cooldown (this module does NOT reimplement them).
|
|
16
17
|
* - `startTokenRefreshScheduler` — a proactive scheduler decoupled from any
|
|
17
|
-
* React type:
|
|
18
|
-
*
|
|
18
|
+
* React type: re-mints ~60s before `exp`, re-arms on token change + web
|
|
19
|
+
* tab-focus, `.unref?.()`s its timer in Node.
|
|
19
20
|
*
|
|
20
21
|
* Framework-free; no module-level mutable state.
|
|
21
22
|
*/
|
|
@@ -24,33 +25,36 @@ import type { AuthRefreshHandler } from '../HttpService';
|
|
|
24
25
|
import type { AuthStateStore } from './authStateStore';
|
|
25
26
|
/**
|
|
26
27
|
* Lead time (ms) before access-token expiry at which the proactive scheduler
|
|
27
|
-
*
|
|
28
|
-
* (60s) so the scheduled
|
|
29
|
-
*
|
|
28
|
+
* re-mints. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
|
|
29
|
+
* (60s) so the scheduled re-mint and the request-time preflight use the same
|
|
30
|
+
* window — the scheduler just fires it during idle/background.
|
|
30
31
|
*/
|
|
31
32
|
export declare const TOKEN_REFRESH_LEAD_MS = 60000;
|
|
32
33
|
export interface RefreshDeps {
|
|
33
34
|
oxy: OxyServices;
|
|
34
35
|
store: AuthStateStore;
|
|
35
36
|
/**
|
|
36
|
-
* Whether to fall back to the native shared-keychain re-mint (arm 2) when
|
|
37
|
-
*
|
|
38
|
-
*
|
|
37
|
+
* Whether to fall back to the native shared-keychain re-mint (arm 2) when the
|
|
38
|
+
* persisted secret is absent / rejected. Defaults to `isNative()` — web has no
|
|
39
|
+
* shared keychain. Exposed for tests.
|
|
39
40
|
*/
|
|
40
41
|
allowSharedKeyFallback?: boolean;
|
|
41
42
|
}
|
|
42
43
|
/**
|
|
43
|
-
*
|
|
44
|
+
* Re-mint the persisted session and return the fresh access token, or `null`
|
|
44
45
|
* when no arm could produce one.
|
|
45
46
|
*
|
|
46
|
-
* Arm 1 (`POST /
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* Arm 1 (`POST /session/device/token`): if the store holds a `deviceId` +
|
|
48
|
+
* `deviceSecret`, mint — on success plant + persist the rotated secret. A 401
|
|
49
|
+
* means the secret is diverged (`invalid_device_secret`) or the device has no
|
|
50
|
+
* live session (`no_active_session`): drop the secret so the mint lane stops (or
|
|
51
|
+
* clear the store on web, where there is no fallback). A transient error leaves
|
|
52
|
+
* the store and returns `null`.
|
|
49
53
|
*
|
|
50
|
-
* Arm 2 (native shared-keychain): when
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
+
* Arm 2 (native shared-keychain): when the secret is absent or was just rejected,
|
|
55
|
+
* re-mint via `signInWithSharedIdentity` (which plants tokens). The shared
|
|
56
|
+
* keychain — not the per-origin store — is the durable native credential, so this
|
|
57
|
+
* arm does not write the store.
|
|
54
58
|
*/
|
|
55
59
|
export declare function refreshPersistedSession(deps: RefreshDeps): Promise<string | null>;
|
|
56
60
|
/**
|
|
@@ -71,7 +75,7 @@ export interface TokenRefreshSchedulerHandle {
|
|
|
71
75
|
dispose(): void;
|
|
72
76
|
}
|
|
73
77
|
/**
|
|
74
|
-
* Start the proactive
|
|
78
|
+
* Start the proactive re-mint scheduler against `oxy`.
|
|
75
79
|
*
|
|
76
80
|
* Schedules a single timer to fire {@link TOKEN_REFRESH_LEAD_MS} before the
|
|
77
81
|
* current access token's `exp`, calling
|
|
@@ -79,11 +83,11 @@ export interface TokenRefreshSchedulerHandle {
|
|
|
79
83
|
* handler; deduped + cooldown-guarded). After every attempt it reschedules
|
|
80
84
|
* from the possibly-rotated token. It also reschedules whenever the token
|
|
81
85
|
* changes (a sign-out that clears the token cancels the timer) and, on web
|
|
82
|
-
* tab-focus,
|
|
86
|
+
* tab-focus, re-mints immediately if already inside the lead window (a
|
|
83
87
|
* long-hidden tab throttles timers, so the token can be expired on return).
|
|
84
88
|
*
|
|
85
89
|
* No-ops cleanly when there is no token or an opaque/no-`exp` token — the
|
|
86
|
-
* reactive 401 path stays the only
|
|
90
|
+
* reactive 401 path stays the only re-mint trigger in that case. The timer is
|
|
87
91
|
* `.unref?.()`-ed so it never keeps a Node/Jest event loop alive.
|
|
88
92
|
*/
|
|
89
93
|
export declare function startTokenRefreshScheduler(oxy: OxyServices): TokenRefreshSchedulerHandle;
|