@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.
Files changed (68) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/boot/coldBootV2.js +66 -281
  3. package/dist/cjs/crypto/keyManager.js +0 -95
  4. package/dist/cjs/index.js +6 -19
  5. package/dist/cjs/mixins/OxyServices.auth.js +4 -7
  6. package/dist/cjs/mixins/OxyServices.deviceBoot.js +24 -92
  7. package/dist/cjs/mixins/index.js +2 -3
  8. package/dist/cjs/session/SessionClient.js +29 -100
  9. package/dist/cjs/session/accountDialogController.js +0 -13
  10. package/dist/cjs/session/authStateStore.js +14 -88
  11. package/dist/cjs/session/createSessionClient.js +2 -9
  12. package/dist/cjs/session/refresh.js +46 -62
  13. package/dist/cjs/utils/registrableApex.js +2 -6
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/boot/coldBootV2.js +67 -279
  16. package/dist/esm/crypto/keyManager.js +0 -95
  17. package/dist/esm/index.js +7 -11
  18. package/dist/esm/mixins/OxyServices.auth.js +4 -7
  19. package/dist/esm/mixins/OxyServices.deviceBoot.js +25 -93
  20. package/dist/esm/mixins/index.js +2 -3
  21. package/dist/esm/session/SessionClient.js +29 -100
  22. package/dist/esm/session/accountDialogController.js +0 -13
  23. package/dist/esm/session/authStateStore.js +13 -87
  24. package/dist/esm/session/createSessionClient.js +2 -9
  25. package/dist/esm/session/refresh.js +46 -62
  26. package/dist/esm/utils/registrableApex.js +2 -6
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/HttpService.d.ts +3 -3
  29. package/dist/types/boot/coldBootV2.d.ts +28 -53
  30. package/dist/types/crypto/keyManager.d.ts +0 -21
  31. package/dist/types/index.d.ts +3 -5
  32. package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
  33. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +22 -46
  34. package/dist/types/session/SessionClient.d.ts +4 -38
  35. package/dist/types/session/accountDialogController.d.ts +1 -3
  36. package/dist/types/session/authStateStore.d.ts +38 -43
  37. package/dist/types/session/createSessionClient.d.ts +2 -9
  38. package/dist/types/session/refresh.d.ts +32 -28
  39. package/dist/types/utils/registrableApex.d.ts +2 -6
  40. package/package.json +2 -2
  41. package/src/HttpService.ts +3 -3
  42. package/src/boot/__tests__/coldBootV2.test.ts +237 -236
  43. package/src/boot/coldBootV2.ts +92 -333
  44. package/src/crypto/keyManager.ts +0 -101
  45. package/src/index.ts +7 -30
  46. package/src/mixins/OxyServices.auth.ts +5 -9
  47. package/src/mixins/OxyServices.deviceBoot.ts +30 -115
  48. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +36 -80
  49. package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
  50. package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
  51. package/src/mixins/index.ts +2 -3
  52. package/src/session/SessionClient.ts +29 -120
  53. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
  54. package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
  55. package/src/session/__tests__/authStateStore.test.ts +47 -33
  56. package/src/session/__tests__/refresh.test.ts +72 -44
  57. package/src/session/accountDialogController.ts +4 -18
  58. package/src/session/authStateStore.ts +43 -111
  59. package/src/session/createSessionClient.ts +2 -9
  60. package/src/session/refresh.ts +60 -83
  61. package/src/utils/registrableApex.ts +2 -6
  62. package/dist/cjs/boot/deviceBootReturn.js +0 -152
  63. package/dist/esm/boot/deviceBootReturn.js +0 -146
  64. package/dist/types/boot/deviceBootReturn.d.ts +0 -83
  65. package/src/boot/__tests__/deviceBootReturn.test.ts +0 -158
  66. package/src/boot/deviceBootReturn.ts +0 -195
  67. package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
  68. 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 refresh endpoint (`POST
34
- * /auth/refresh-token`): it does not need a bearer, and — critically — it is
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
- * refresh call fully independent of the current (near-expired) bearer.
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
- import { type DeviceBootSession } from './deviceBootReturn';
5
- /**
6
- * localStorage flag marking that the cross-apex bootstrap navigation has fired
7
- * once for this origin. Persistent (not session) so the visible redirect
8
- * happens AT MOST ONCE EVER per browser+origin — a signed-out user is never
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 (not while navigating away). */
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
- * or `onSignedOut` (no session — unless the boot is navigating away for the
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
  *
@@ -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, DEVICE_TOKEN_STORAGE_KEY, } from './session/authStateStore';
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, createBrowserColdBootDom, isSameApex, BOOT_ATTEMPTED_KEY, } from './boot/coldBootV2';
103
- export type { RunSessionColdBootOptions, ColdBootDom, SignedOutReason, } from './boot/coldBootV2';
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
- * `options.deviceToken` attributes the new session to the caller's existing
473
- * device set (so an in-app login from a cross-apex, cookie-less web app
474
- * still joins the same DeviceSession). On the session arm, a returned access
475
- * token is planted immediately (mirroring {@link verifyChallenge}), so the
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 bootstrap mixin (auth centralization, wave 1).
2
+ * Device-first token mint mixin.
3
3
  *
4
- * The client half of the new device-first session bootstrap: the four network
5
- * calls the cold boot (`coldBootV2`) and the unified refresh handler
6
- * (`refresh.ts`) make, plus the URL builder for the cross-apex bootstrap hop.
7
- * Every response is validated against the `@oxyhq/contracts` `deviceBoot`
8
- * schemas via `safeParseContract`, so producer (oxy-api) and consumer cannot
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
- * These methods are ADDITIVE and carry NO persistence or token-planting side
13
- * effects of their own (except the bearer-authenticated calls that naturally
14
- * flow through `HttpService`). The cold boot / refresh handler own persistence
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 AuthTokenBundle, type TokenRefreshResponse, type WebSessionResult } from '@oxyhq/contracts';
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
- * Exchange a single-use boot `code` (from the `#oxy_boot` return fragment)
24
- * for a token bundle. Origin-bound + GETDEL-burned server-side.
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
- * @throws if the response does not match {@link authTokenBundleSchema}.
27
- */
28
- exchangeBootCode(code: string): Promise<AuthTokenBundle>;
29
- /**
30
- * Same-site fast path (`*.oxy.so` apps). Reads the first-party `oxy_device`
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 tokenRefreshResponseSchema}.
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
- buildBootstrapUrl(returnTo: string, state: string): string;
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
- /** In-flight guard so a burst of pushes triggers at most ONE acquisition. */
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 a session posts a wake ping; on receipt a signed-in tab re-syncs its
130
- * device state and a signed-out tab self-acquires — instant + network-free for
131
- * the common "two tabs of the same origin" case, with no state (and no tokens)
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 device-first session model (auth-centralization wave 1) persists the
5
- * rotating refresh-token family head per ORIGIN so a reload restores the
6
- * session locally without a redirect. This module is the storage seam: a tiny
7
- * `load / save / clear` interface plus platform factories, so the cold boot
8
- * (`coldBootV2`) and the unified refresh handler (`refresh.ts`) never touch a
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
- * `refreshToken` is the rotating single-use family head; `sessionId` + `userId`
23
- * identify the owning device session and account. `deviceToken` is the opaque,
24
- * add-only device attribution token (mirrored to the shared keychain on native
25
- * so every Oxy app on one phone shares one DeviceSession).
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
- * lets the cold boot plant a still-valid access token on the very first paint
29
- * WITHOUT a blocking `/auth/refresh-token` round-trip — the proactive scheduler
30
- * then rotates it in the background. They are a strict optimization: the store
31
- * is fully functional (via `refreshToken`) when they are absent or stale, and
32
- * the access token is short-lived, so persisting it adds no exposure the
33
- * already-persisted refresh token does not (see the plan's XSS risk note — the
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
- deviceToken?: string;
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 refresh handler. Async
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 persisted-refresh lane treats a failed persist as "no
108
- * durable state" (falls back to the bootstrap hop) rather than crashing.
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 SessionClientOptions, type TokenTransport } from './SessionClient';
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 refresh implementation for web + native.
2
+ * Unified token refresh — THE single access-token re-mint for web + native.
3
3
  *
4
- * ONE persisted-refresh-token rotation shared by every consumer (it replaced
5
- * the pre-device-first per-platform duplicates):
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 rotates the stored refresh-token family
8
- * (`POST /auth/refresh-token`), planting + persisting the rotated pair; arm 2
9
- * (native only) re-mints via the shared-keychain identity when there is no
10
- * live refresh token. It is used BOTH reactively (wrapped as the
11
- * `AuthRefreshHandler` installed on `HttpService`) AND proactively (the
12
- * cold-boot `stored-tokens` step calls it directly).
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: refreshes ~60s before `exp`,
18
- * re-arms on token change + web tab-focus, `.unref?.()`s its timer in Node.
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
- * refreshes. Mirrors `HttpService`'s per-request `TOKEN_REFRESH_LEAD_SECONDS`
28
- * (60s) so the scheduled refresh and the request-time preflight refresh use
29
- * the same window — the scheduler just fires it during idle/background.
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
- * there is no live refresh token / arm 1 is revoked. Defaults to `isNative()`
38
- * — web has no shared keychain. Exposed for tests.
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
- * Rotate the persisted session and return the fresh access token, or `null`
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 /auth/refresh-token`): if the store holds a refresh token, rotate
47
- * it — on success plant + persist the rotated pair; on a REVOKED error clear the
48
- * store; on a transient error leave the store and return `null`.
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 there is no refresh token or arm 1 was
51
- * revoked, re-mint via `signInWithSharedIdentity` (which plants tokens). The
52
- * shared keychain — not the per-origin store — is the durable native credential,
53
- * so this arm does not write the store.
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 refresh scheduler against `oxy`.
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, refreshes immediately if already inside the lead window (a
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 refresh trigger in that case. The timer is
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;