@oxyhq/core 8.1.0 → 9.1.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 (71) hide show
  1. package/README.md +1 -3
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/boot/coldBootV2.js +22 -332
  4. package/dist/cjs/crypto/keyManager.js +0 -95
  5. package/dist/cjs/index.js +6 -19
  6. package/dist/cjs/mixins/OxyServices.auth.js +4 -7
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
  8. package/dist/cjs/mixins/index.js +2 -3
  9. package/dist/cjs/session/SessionClient.js +29 -100
  10. package/dist/cjs/session/accountDialogController.js +0 -13
  11. package/dist/cjs/session/authStateStore.js +9 -89
  12. package/dist/cjs/session/createSessionClient.js +2 -9
  13. package/dist/cjs/session/refresh.js +46 -71
  14. package/dist/cjs/utils/registrableApex.js +2 -6
  15. package/dist/esm/.tsbuildinfo +1 -1
  16. package/dist/esm/boot/coldBootV2.js +23 -330
  17. package/dist/esm/crypto/keyManager.js +0 -95
  18. package/dist/esm/index.js +7 -11
  19. package/dist/esm/mixins/OxyServices.auth.js +4 -7
  20. package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
  21. package/dist/esm/mixins/index.js +2 -3
  22. package/dist/esm/session/SessionClient.js +29 -100
  23. package/dist/esm/session/accountDialogController.js +0 -13
  24. package/dist/esm/session/authStateStore.js +8 -88
  25. package/dist/esm/session/createSessionClient.js +2 -9
  26. package/dist/esm/session/refresh.js +46 -71
  27. package/dist/esm/utils/registrableApex.js +2 -6
  28. package/dist/types/.tsbuildinfo +1 -1
  29. package/dist/types/HttpService.d.ts +3 -3
  30. package/dist/types/boot/coldBootV2.d.ts +28 -53
  31. package/dist/types/crypto/keyManager.d.ts +0 -21
  32. package/dist/types/index.d.ts +3 -5
  33. package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
  34. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
  35. package/dist/types/models/interfaces.d.ts +15 -0
  36. package/dist/types/session/SessionClient.d.ts +6 -40
  37. package/dist/types/session/accountDialogController.d.ts +1 -3
  38. package/dist/types/session/authStateStore.d.ts +32 -57
  39. package/dist/types/session/createSessionClient.d.ts +2 -9
  40. package/dist/types/session/refresh.d.ts +32 -28
  41. package/dist/types/utils/registrableApex.d.ts +2 -6
  42. package/package.json +3 -3
  43. package/src/HttpService.ts +3 -3
  44. package/src/boot/__tests__/coldBootV2.test.ts +140 -353
  45. package/src/boot/coldBootV2.ts +35 -391
  46. package/src/crypto/keyManager.ts +0 -101
  47. package/src/index.ts +7 -30
  48. package/src/mixins/OxyServices.auth.ts +5 -9
  49. package/src/mixins/OxyServices.deviceBoot.ts +19 -142
  50. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
  51. package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
  52. package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
  53. package/src/mixins/index.ts +2 -3
  54. package/src/models/interfaces.ts +15 -0
  55. package/src/session/SessionClient.ts +31 -122
  56. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
  57. package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
  58. package/src/session/__tests__/authStateStore.test.ts +29 -42
  59. package/src/session/__tests__/refresh.test.ts +66 -52
  60. package/src/session/accountDialogController.ts +4 -18
  61. package/src/session/authStateStore.ts +32 -126
  62. package/src/session/createSessionClient.ts +2 -9
  63. package/src/session/refresh.ts +60 -92
  64. package/src/utils/registrableApex.ts +2 -6
  65. package/dist/cjs/boot/deviceBootReturn.js +0 -167
  66. package/dist/esm/boot/deviceBootReturn.js +0 -161
  67. package/dist/types/boot/deviceBootReturn.d.ts +0 -83
  68. package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
  69. package/src/boot/deviceBootReturn.ts +0 -210
  70. package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
  71. package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
@@ -1,25 +1,33 @@
1
1
  import type { OxyServices } from '../../OxyServices';
2
- import type { TokenRefreshResponse } from '@oxyhq/contracts';
2
+ import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
3
3
  import type { SessionLoginResponse } from '../../models/session';
4
4
  import { refreshPersistedSession, startTokenRefreshScheduler } from '../refresh';
5
5
  import { createMemoryAuthStateStore, type PersistedAuthState } from '../authStateStore';
6
6
 
7
+ /** The persisted zero-cookie mint credential the refresh reads. */
7
8
  const STORED: PersistedAuthState = {
8
9
  sessionId: 'sess-old',
9
- refreshToken: 'refresh-old-abcdefghij',
10
10
  userId: 'user-1',
11
- deviceToken: 'device-abcdefghij',
11
+ deviceId: 'dev-mint',
12
+ deviceSecret: 'ds-secret-orig',
12
13
  };
13
14
 
14
- const ROTATED: TokenRefreshResponse = {
15
+ /** A successful `POST /session/device/token` mint (rotates the secret + active account). */
16
+ const MINT: DeviceTokenMintResponse = {
15
17
  accessToken: 'access-new',
16
- refreshToken: 'refresh-new-abcdefghij',
17
18
  expiresAt: '2030-01-01T00:00:00.000Z',
18
- sessionId: 'sess-new',
19
+ nextDeviceSecret: 'ds-next-secret',
20
+ state: {
21
+ deviceId: 'dev-mint',
22
+ accounts: [{ accountId: 'user-1', sessionId: 'sess-new', authuser: 0 }],
23
+ activeAccountId: 'user-1',
24
+ revision: 4,
25
+ updatedAt: 1_700_000_000_000,
26
+ },
19
27
  };
20
28
 
21
29
  interface RefreshMockOverrides {
22
- refreshWithToken?: OxyServices['refreshWithToken'];
30
+ mintFromDeviceSecret?: OxyServices['mintFromDeviceSecret'];
23
31
  signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
24
32
  }
25
33
 
@@ -27,14 +35,14 @@ function makeOxy(overrides: RefreshMockOverrides = {}): { oxy: OxyServices; setT
27
35
  const setTokens = jest.fn();
28
36
  const oxy = {
29
37
  setTokens,
30
- refreshWithToken: overrides.refreshWithToken ?? (async () => ROTATED),
38
+ mintFromDeviceSecret: overrides.mintFromDeviceSecret ?? (async () => MINT),
31
39
  signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
32
40
  } as unknown as OxyServices;
33
41
  return { oxy, setTokens };
34
42
  }
35
43
 
36
- describe('refreshPersistedSession — arm 1 (refresh-token rotation)', () => {
37
- it('rotates, plants, persists the new family head, and returns the new token', async () => {
44
+ describe('refreshPersistedSession — arm 1 (device-secret mint)', () => {
45
+ it('mints, plants, persists the rotated secret + active account, and returns the token', async () => {
38
46
  const store = createMemoryAuthStateStore();
39
47
  await store.save(STORED);
40
48
  const { oxy, setTokens } = makeOxy();
@@ -45,67 +53,82 @@ describe('refreshPersistedSession — arm 1 (refresh-token rotation)', () => {
45
53
  expect(setTokens).toHaveBeenCalledWith('access-new');
46
54
  expect(await store.load()).toEqual({
47
55
  sessionId: 'sess-new',
48
- refreshToken: 'refresh-new-abcdefghij',
49
56
  userId: 'user-1',
50
- deviceToken: 'device-abcdefghij',
57
+ deviceId: 'dev-mint',
58
+ deviceSecret: 'ds-next-secret',
51
59
  accessToken: 'access-new',
52
60
  expiresAt: '2030-01-01T00:00:00.000Z',
53
61
  });
54
62
  });
55
63
 
56
- it('carries the persisted deviceId + deviceSecret (phase 2c) forward across a rotation', async () => {
64
+ it('presents the persisted deviceId + deviceSecret to the mint', async () => {
57
65
  const store = createMemoryAuthStateStore();
58
- await store.save({ ...STORED, deviceId: 'dev-mint', deviceSecret: 'ds-secret-orig' });
59
- const { oxy } = makeOxy();
66
+ await store.save(STORED);
67
+ const mintFromDeviceSecret = jest.fn(async () => MINT);
68
+ const { oxy } = makeOxy({ mintFromDeviceSecret });
60
69
 
61
70
  await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
62
71
 
63
- const persisted = await store.load();
64
- expect(persisted?.deviceId).toBe('dev-mint');
65
- expect(persisted?.deviceSecret).toBe('ds-secret-orig');
66
- // The refresh family head still rotated.
67
- expect(persisted?.refreshToken).toBe('refresh-new-abcdefghij');
72
+ expect(mintFromDeviceSecret).toHaveBeenCalledWith('dev-mint', 'ds-secret-orig');
68
73
  });
69
74
 
70
- it('clears the store on a family-revoked (401) error', async () => {
75
+ it('drops only the secret (keeps deviceId) on a 401 and falls to shared-key when native', async () => {
71
76
  const store = createMemoryAuthStateStore();
72
77
  await store.save(STORED);
78
+ const SHARED: SessionLoginResponse = {
79
+ sessionId: 'sess-shared',
80
+ deviceId: 'dev-1',
81
+ expiresAt: '2030-01-01T00:00:00.000Z',
82
+ user: { id: 'user-1', username: 'u', name: {}, avatar: undefined },
83
+ accessToken: 'access-shared',
84
+ };
85
+ const signInWithSharedIdentity = jest.fn(async () => SHARED);
73
86
  const { oxy } = makeOxy({
74
- refreshWithToken: async () => {
75
- throw Object.assign(new Error('revoked'), { status: 401 });
87
+ mintFromDeviceSecret: async () => {
88
+ throw Object.assign(new Error('invalid_device_secret'), { status: 401 });
76
89
  },
90
+ signInWithSharedIdentity,
77
91
  });
78
92
 
79
- const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
93
+ const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
80
94
 
81
- expect(token).toBeNull();
82
- expect(await store.load()).toBeNull();
95
+ expect(token).toBe('access-shared');
96
+ expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
97
+ const persisted = await store.load();
98
+ expect(persisted?.deviceSecret).toBeUndefined();
99
+ expect(persisted?.deviceId).toBe('dev-mint');
83
100
  });
84
101
 
85
- it('clears the store on an invalid_grant code', async () => {
102
+ it('clears the store on a 401 when there is no shared-key fallback (web)', async () => {
86
103
  const store = createMemoryAuthStateStore();
87
104
  await store.save(STORED);
88
105
  const { oxy } = makeOxy({
89
- refreshWithToken: async () => {
90
- throw Object.assign(new Error('bad'), { code: 'invalid_grant' });
106
+ mintFromDeviceSecret: async () => {
107
+ throw Object.assign(new Error('no_active_session'), { status: 401 });
91
108
  },
92
109
  });
93
110
 
94
- expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
111
+ const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
112
+
113
+ expect(token).toBeNull();
95
114
  expect(await store.load()).toBeNull();
96
115
  });
97
116
 
98
- it('KEEPS the store on a transient (500 / network) error', async () => {
117
+ it('KEEPS the store and returns null on a transient (500 / network) error', async () => {
99
118
  const store = createMemoryAuthStateStore();
100
119
  await store.save(STORED);
120
+ const signInWithSharedIdentity = jest.fn(async () => null);
101
121
  const { oxy } = makeOxy({
102
- refreshWithToken: async () => {
122
+ mintFromDeviceSecret: async () => {
103
123
  throw Object.assign(new Error('server'), { status: 500 });
104
124
  },
125
+ signInWithSharedIdentity,
105
126
  });
106
127
 
107
- expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
128
+ // A transient failure must NOT drop the credential nor fall through to shared-key.
129
+ expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).toBeNull();
108
130
  expect(await store.load()).toEqual(STORED);
131
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
109
132
  });
110
133
  });
111
134
 
@@ -118,8 +141,8 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
118
141
  accessToken: 'access-shared',
119
142
  };
120
143
 
121
- it('re-mints via shared identity when there is no refresh token', async () => {
122
- const store = createMemoryAuthStateStore(); // no stored refresh token
144
+ it('re-mints via shared identity when there is no persisted secret', async () => {
145
+ const store = createMemoryAuthStateStore(); // no stored device secret
123
146
  const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
124
147
  const { oxy } = makeOxy({ signInWithSharedIdentity });
125
148
 
@@ -129,31 +152,22 @@ describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () =>
129
152
  expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
130
153
  });
131
154
 
132
- it('falls back to shared-key after a revoked refresh token', async () => {
155
+ it('does NOT try shared-key when the fallback is disabled (web)', async () => {
133
156
  const store = createMemoryAuthStateStore();
134
- await store.save(STORED);
135
157
  const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
136
- const { oxy } = makeOxy({
137
- refreshWithToken: async () => {
138
- throw Object.assign(new Error('revoked'), { status: 403 });
139
- },
140
- signInWithSharedIdentity,
141
- });
142
-
143
- const token = await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true });
158
+ const { oxy } = makeOxy({ signInWithSharedIdentity });
144
159
 
145
- expect(token).toBe('access-shared');
146
- expect(await store.load()).toBeNull(); // revoked family was cleared
147
- expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
160
+ expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
161
+ expect(signInWithSharedIdentity).not.toHaveBeenCalled();
148
162
  });
149
163
 
150
- it('does NOT try shared-key when the fallback is disabled (web)', async () => {
164
+ it('returns null when the shared-key re-mint yields no session', async () => {
151
165
  const store = createMemoryAuthStateStore();
152
- const signInWithSharedIdentity = jest.fn(async () => SHARED_SESSION);
166
+ const signInWithSharedIdentity = jest.fn(async () => null);
153
167
  const { oxy } = makeOxy({ signInWithSharedIdentity });
154
168
 
155
- expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false })).toBeNull();
156
- expect(signInWithSharedIdentity).not.toHaveBeenCalled();
169
+ expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).toBeNull();
170
+ expect(signInWithSharedIdentity).toHaveBeenCalledTimes(1);
157
171
  });
158
172
  });
159
173
 
@@ -106,7 +106,7 @@ export interface AccountDialogControllerOptions {
106
106
  * falls back to `SessionClient.registerAndActivate` (registration + activation
107
107
  * only — no provider-side durable persist/hydration).
108
108
  */
109
- commitSession?: (session: SessionLoginResponse & { refreshToken?: string }) => Promise<void>;
109
+ commitSession?: (session: SessionLoginResponse) => Promise<void>;
110
110
  /** Notified after a completed sign-in (bearer planted + session committed). */
111
111
  onSignedIn?: (user: MinimalUserData) => void;
112
112
  /** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
@@ -142,7 +142,7 @@ export class AccountDialogController {
142
142
  private readonly sessionClient: SessionClient;
143
143
  private readonly clientId: string | null;
144
144
  private readonly locale?: string;
145
- private readonly commitSession?: (session: SessionLoginResponse & { refreshToken?: string }) => Promise<void>;
145
+ private readonly commitSession?: (session: SessionLoginResponse) => Promise<void>;
146
146
  private readonly onSignedIn?: (user: MinimalUserData) => void;
147
147
  private readonly idpApex: string;
148
148
  private readonly pollIntervalMs: number;
@@ -454,7 +454,6 @@ export class AccountDialogController {
454
454
  expiresAt: result.expiresAt,
455
455
  user: result.user,
456
456
  accessToken: result.accessToken,
457
- ...(readRefreshToken(result) ? { refreshToken: readRefreshToken(result) } : {}),
458
457
  },
459
458
  result.user,
460
459
  );
@@ -633,7 +632,6 @@ export class AccountDialogController {
633
632
  name: claimed.user.name,
634
633
  avatar: claimed.user.avatar ?? undefined,
635
634
  };
636
- const refreshToken = readRefreshToken(claimed);
637
635
  try {
638
636
  await this.completeSignIn(
639
637
  {
@@ -642,7 +640,6 @@ export class AccountDialogController {
642
640
  expiresAt: claimed.expiresAt ?? '',
643
641
  user: minimalUser,
644
642
  accessToken: claimed.accessToken,
645
- ...(refreshToken ? { refreshToken } : {}),
646
643
  },
647
644
  minimalUser,
648
645
  );
@@ -656,7 +653,7 @@ export class AccountDialogController {
656
653
  * by the shared-key, QR, and mint-switch paths so they cannot drift.
657
654
  */
658
655
  private async completeSignIn(
659
- session: SessionLoginResponse & { refreshToken?: string },
656
+ session: SessionLoginResponse,
660
657
  user: MinimalUserData,
661
658
  ): Promise<void> {
662
659
  await this.commitAuthorizedSession(session, user);
@@ -675,7 +672,7 @@ export class AccountDialogController {
675
672
  * `SessionClient.registerAndActivate` (registration + activation only).
676
673
  */
677
674
  private async commitAuthorizedSession(
678
- session: SessionLoginResponse & { refreshToken?: string },
675
+ session: SessionLoginResponse,
679
676
  user: MinimalUserData,
680
677
  ): Promise<void> {
681
678
  if (this.commitSession) {
@@ -751,17 +748,6 @@ export function createAccountDialogController(
751
748
  // Local helpers
752
749
  // ---------------------------------------------------------------------------
753
750
 
754
- /**
755
- * The rotating refresh-token family head is threaded on the runtime object by
756
- * the trusted device-flow / switch lanes even though it is NOT on the typed
757
- * return of `claimSessionByToken` / `switchToAccount`. Read it defensively so
758
- * the commit funnel can persist a durable session.
759
- */
760
- function readRefreshToken(value: unknown): string | undefined {
761
- const token = (value as { refreshToken?: unknown }).refreshToken;
762
- return typeof token === 'string' ? token : undefined;
763
- }
764
-
765
751
  /** Current document URL on web; empty string where `location` is absent (native/SSR). */
766
752
  function currentLocationHref(): string {
767
753
  const location = (globalThis as { location?: { href?: string } }).location;
@@ -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`
@@ -20,43 +20,36 @@
20
20
  /**
21
21
  * The persisted session credential set for a single origin.
22
22
  *
23
- * `refreshToken` is the rotating single-use family head; `sessionId` + `userId`
24
- * identify the owning device session and account. `deviceToken` is the opaque,
25
- * add-only device attribution token (mirrored to the shared keychain on native
26
- * so every Oxy app on one phone shares one DeviceSession).
23
+ * `sessionId` + `userId` identify the owning device session and active account.
24
+ * `deviceId` + `deviceSecret` are the zero-cookie mint credential: the client
25
+ * presents BOTH at `POST /session/device/token` the secret is the proof, the
26
+ * deviceId selects the device doc. The secret is rotated in-use: the mint returns
27
+ * `nextDeviceSecret`, which the cold boot / re-mint handler persist BEFORE
28
+ * planting the minted access token (multi-tab anti-loss).
27
29
  *
28
- * `accessToken` + `expiresAt` are OPTIONAL warm-boot fields. Persisting them
29
- * lets the cold boot plant a still-valid access token on the very first paint
30
- * WITHOUT a blocking `/auth/refresh-token` round-trip — the proactive scheduler
31
- * then rotates it in the background. They are a strict optimization: the store
32
- * is fully functional (via `refreshToken`) when they are absent or stale, and
33
- * the access token is short-lived, so persisting it adds no exposure the
34
- * already-persisted refresh token does not (see the plan's XSS risk note — the
35
- * refresh token is the dominant secret either way).
30
+ * `accessToken` + `expiresAt` are OPTIONAL warm-boot fields. Persisting them lets
31
+ * the cold boot plant a still-valid access token on the very first paint WITHOUT
32
+ * a blocking mint round-trip — the proactive scheduler then rotates it in the
33
+ * background. They are a strict optimization: the store is fully functional (via
34
+ * `deviceSecret`) when they are absent or stale, and the access token is
35
+ * short-lived, so persisting it adds no exposure the already-persisted
36
+ * `deviceSecret` does not.
36
37
  */
37
38
  export interface PersistedAuthState {
38
39
  sessionId: string;
39
- refreshToken: string;
40
40
  userId: string;
41
- deviceToken?: string;
42
41
  /**
43
- * The stable device identifier this session is bound to (phase 2c —
44
- * zero-cookie transport). Persisted alongside {@link deviceSecret} because the
45
- * `POST /session/device/token` mint presents BOTH the secret is the proof,
46
- * the deviceId selects the device doc. Sourced from the lanes that carry it
47
- * (password login / 2FA / QR claim / challenge verify); the cookie-bootstrap
48
- * lanes (`AuthTokenBundle`) omit it and preserve any prior value. Additive: a
49
- * blob without it simply never takes the mint lane and falls back to the
50
- * refresh family.
42
+ * The stable device identifier this session is bound to (zero-cookie
43
+ * transport). Persisted alongside {@link deviceSecret} because the
44
+ * `POST /session/device/token` mint presents BOTH. Sourced from every login
45
+ * lane (password / 2FA / QR claim / challenge verify) via the response's
46
+ * `deviceId`.
51
47
  */
52
48
  deviceId?: string;
53
49
  /**
54
- * The rotating device secret (phase 2c — zero-cookie transport). Possession of
55
- * it mints a short access token for the device's active account via
56
- * `POST /session/device/token`, replacing the cookie lane. Rotated in-use: the
57
- * mint returns `nextDeviceSecret`, which the cold boot persists BEFORE planting
58
- * the minted access token (multi-tab anti-loss). Additive and optional — same
59
- * XSS risk profile as the already-persisted `refreshToken`.
50
+ * The rotating device secret (zero-cookie transport). Possession of it mints a
51
+ * short access token for the device's active account via
52
+ * `POST /session/device/token`. Rotated in-use.
60
53
  */
61
54
  deviceSecret?: string;
62
55
  /** Optional warm-boot access token (short-lived; see interface docs). */
@@ -66,27 +59,15 @@ export interface PersistedAuthState {
66
59
  }
67
60
 
68
61
  /**
69
- * The storage seam consumed by the cold boot and the refresh handler. Async
62
+ * The storage seam consumed by the cold boot and the re-mint handler. Async
70
63
  * throughout so one interface fits both synchronous web `localStorage` and
71
- * asynchronous native SecureStore/AsyncStorage.
72
- *
73
- * Two lifetimes:
74
- * - The SESSION credential blob (`load`/`save`/`clear`) is per-sign-in and is
75
- * wiped on `clear()` (sign-out).
76
- * - The DEVICE token (`loadDeviceToken`/`saveDeviceToken`/`clearDeviceToken`)
77
- * is long-lived device attribution that SURVIVES `clear()`: a signed-out
78
- * browser is still the same device, and a later in-app (cross-apex,
79
- * cookie-less) login sends this token so the new session joins the SAME
80
- * server-side DeviceSession. Only an explicit device signout-all
81
- * (`clearDeviceToken`) removes it.
64
+ * asynchronous native SecureStore/AsyncStorage. `clear()` (sign-out) wipes the
65
+ * per-sign-in credential blob.
82
66
  */
83
67
  export interface AuthStateStore {
84
68
  load(): Promise<PersistedAuthState | null>;
85
69
  save(state: PersistedAuthState): Promise<void>;
86
70
  clear(): Promise<void>;
87
- loadDeviceToken(): Promise<string | null>;
88
- saveDeviceToken(token: string): Promise<void>;
89
- clearDeviceToken(): Promise<void>;
90
71
  }
91
72
 
92
73
  /**
@@ -106,13 +87,6 @@ export interface NativeKeyValueStorage {
106
87
  */
107
88
  export const AUTH_STATE_STORAGE_KEY = 'oxy.auth.v1';
108
89
 
109
- /**
110
- * Storage key for the long-lived device-attribution token. Separate from
111
- * {@link AUTH_STATE_STORAGE_KEY} because it must OUTLIVE a session `clear()`
112
- * (sign-out) — the device is unchanged across sign-ins.
113
- */
114
- export const DEVICE_TOKEN_STORAGE_KEY = 'oxy.device.v1';
115
-
116
90
  /**
117
91
  * Parse + shape-validate a stored blob. Returns `null` for anything that is
118
92
  * not a well-formed {@link PersistedAuthState} (absent, malformed JSON, wrong
@@ -134,22 +108,16 @@ function deserialize(raw: string | null): PersistedAuthState | null {
134
108
  const candidate = parsed as Record<string, unknown>;
135
109
  if (
136
110
  typeof candidate.sessionId !== 'string' ||
137
- typeof candidate.refreshToken !== 'string' ||
138
111
  typeof candidate.userId !== 'string' ||
139
112
  candidate.sessionId.length === 0 ||
140
- candidate.refreshToken.length === 0 ||
141
113
  candidate.userId.length === 0
142
114
  ) {
143
115
  return null;
144
116
  }
145
117
  const state: PersistedAuthState = {
146
118
  sessionId: candidate.sessionId,
147
- refreshToken: candidate.refreshToken,
148
119
  userId: candidate.userId,
149
120
  };
150
- if (typeof candidate.deviceToken === 'string' && candidate.deviceToken.length > 0) {
151
- state.deviceToken = candidate.deviceToken;
152
- }
153
121
  if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
154
122
  state.deviceId = candidate.deviceId;
155
123
  }
@@ -173,7 +141,6 @@ function deserialize(raw: string | null): PersistedAuthState | null {
173
141
  */
174
142
  export function createMemoryAuthStateStore(): AuthStateStore {
175
143
  let current: PersistedAuthState | null = null;
176
- let deviceToken: string | null = null;
177
144
  return {
178
145
  load: async () => current,
179
146
  save: async (state) => {
@@ -182,13 +149,6 @@ export function createMemoryAuthStateStore(): AuthStateStore {
182
149
  clear: async () => {
183
150
  current = null;
184
151
  },
185
- loadDeviceToken: async () => deviceToken,
186
- saveDeviceToken: async (token) => {
187
- deviceToken = token;
188
- },
189
- clearDeviceToken: async () => {
190
- deviceToken = null;
191
- },
192
152
  };
193
153
  }
194
154
 
@@ -220,8 +180,8 @@ function safeGetLocalStorage(): Storage | null {
220
180
  * for this page's lifetime — never throws on construction.
221
181
  * - Individual `getItem`/`setItem`/`removeItem` are each wrapped: a read that
222
182
  * throws yields `null`; a write that throws (quota, private mode) is
223
- * swallowed. The persisted-refresh lane treats a failed persist as "no
224
- * durable state" (falls back to the bootstrap hop) rather than crashing.
183
+ * swallowed. The re-mint lane treats a failed persist as "no durable state"
184
+ * rather than crashing.
225
185
  */
226
186
  export function createWebAuthStateStore(): AuthStateStore {
227
187
  const storage = safeGetLocalStorage();
@@ -233,7 +193,6 @@ export function createWebAuthStateStore(): AuthStateStore {
233
193
  // means "never written this session" → fall back to storage; any set value
234
194
  // (including `null` after clear) is authoritative and preferred over storage.
235
195
  let sessionMirror: PersistedAuthState | null | undefined;
236
- let deviceTokenMirror: string | null | undefined;
237
196
  return {
238
197
  load: async () => {
239
198
  if (sessionMirror !== undefined) {
@@ -262,32 +221,6 @@ export function createWebAuthStateStore(): AuthStateStore {
262
221
  // Non-fatal — see save().
263
222
  }
264
223
  },
265
- loadDeviceToken: async () => {
266
- if (deviceTokenMirror !== undefined) {
267
- return deviceTokenMirror;
268
- }
269
- try {
270
- return storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
271
- } catch {
272
- return null;
273
- }
274
- },
275
- saveDeviceToken: async (token) => {
276
- deviceTokenMirror = token;
277
- try {
278
- storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
279
- } catch {
280
- // Non-fatal — see save().
281
- }
282
- },
283
- clearDeviceToken: async () => {
284
- deviceTokenMirror = null;
285
- try {
286
- storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
287
- } catch {
288
- // Non-fatal — see save().
289
- }
290
- },
291
224
  };
292
225
  }
293
226
 
@@ -303,7 +236,6 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
303
236
  // Same in-memory mirror as the web store — a locked/failed SecureStore write
304
237
  // must not silently lose the session for the app's lifetime.
305
238
  let sessionMirror: PersistedAuthState | null | undefined;
306
- let deviceTokenMirror: string | null | undefined;
307
239
  return {
308
240
  load: async () => {
309
241
  if (sessionMirror !== undefined) {
@@ -331,31 +263,5 @@ export function createNativeAuthStateStore(storage: NativeKeyValueStorage): Auth
331
263
  // Non-fatal.
332
264
  }
333
265
  },
334
- loadDeviceToken: async () => {
335
- if (deviceTokenMirror !== undefined) {
336
- return deviceTokenMirror;
337
- }
338
- try {
339
- return await storage.getItem(DEVICE_TOKEN_STORAGE_KEY);
340
- } catch {
341
- return null;
342
- }
343
- },
344
- saveDeviceToken: async (token) => {
345
- deviceTokenMirror = token;
346
- try {
347
- await storage.setItem(DEVICE_TOKEN_STORAGE_KEY, token);
348
- } catch {
349
- // Non-fatal.
350
- }
351
- },
352
- clearDeviceToken: async () => {
353
- deviceTokenMirror = null;
354
- try {
355
- await storage.removeItem(DEVICE_TOKEN_STORAGE_KEY);
356
- } catch {
357
- // Non-fatal.
358
- }
359
- },
360
266
  };
361
267
  }
@@ -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
 
@@ -29,18 +29,11 @@ export function createSessionClient(
29
29
  oxyServices: OxyServices,
30
30
  transport: TokenTransport,
31
31
  socketFactory?: SocketIOFactory,
32
- /**
33
- * Optional signed-out realtime wiring: `signedOutSocketAuth` (open the socket
34
- * while signed out so an idle tab receives its device pushes) and
35
- * `onSessionAppeared` (self-acquire when a sibling signs in). See
36
- * {@link SessionClientOptions}.
37
- */
38
- extra?: Pick<SessionClientOptions, 'signedOutSocketAuth' | 'onSessionAppeared'>,
39
32
  ): {
40
33
  client: SessionClient;
41
34
  host: ReturnType<typeof createSessionClientHost>;
42
35
  } {
43
36
  const host = createSessionClientHost(oxyServices);
44
- const client = new SessionClient(host, { transport, socketFactory, ...extra });
37
+ const client = new SessionClient(host, { transport, socketFactory });
45
38
  return { client, host };
46
39
  }