@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,83 +0,0 @@
1
- /**
2
- * Device-boot return-fragment consumption (web cross-apex hop).
3
- *
4
- * After the top-level `GET /auth/device/bootstrap` hop, the API 303s back to the
5
- * RP with a `#oxy_boot=<base64url(JSON)>` fragment. This module parses and
6
- * consumes it: it strips the fragment from the URL FIRST (so the opaque
7
- * deviceToken / code never linger in history or a `Referer`), verifies the
8
- * echoed CSRF `state` against the value the initiator stashed in
9
- * `sessionStorage`, persists the deviceToken, and — when a session resolved —
10
- * exchanges the single-use `code` for a token bundle.
11
- *
12
- * Pure/injectable: all DOM access (hash, `history.replaceState`,
13
- * `sessionStorage`) is passed in as callbacks so the logic is unit-testable
14
- * under the jest `node` environment and reusable by `coldBootV2`.
15
- *
16
- * ESM-safe (no `require()`).
17
- */
18
- import { type AuthTokenBundle, type DeviceBootFragment, type DeviceBootReason } from '@oxyhq/contracts';
19
- import type { AuthStateStore } from '../session/authStateStore';
20
- /** The `#oxy_boot=` fragment parameter name the API appends on the return hop. */
21
- export declare const BOOT_FRAGMENT_PARAM = "oxy_boot";
22
- /**
23
- * `sessionStorage` key under which the bootstrap-hop initiator stashes the
24
- * 128-bit CSRF `state` before navigating, and which the return step reads back
25
- * (single-use).
26
- */
27
- export declare const BOOT_STATE_SESSION_KEY = "oxy.boot.state";
28
- /** True when a location hash carries the `oxy_boot` return fragment. */
29
- export declare function hashHasBootFragment(hash: string): boolean;
30
- /**
31
- * Extract + decode + validate the `oxy_boot` fragment from a location hash.
32
- * Returns the parsed {@link DeviceBootFragment}, or `null` when the parameter
33
- * is absent, not valid base64url, not JSON, or fails the contract schema.
34
- */
35
- export declare function parseDeviceBootFragment(hash: string): DeviceBootFragment | null;
36
- /** The winning session shape a cold-boot step reports. */
37
- export interface DeviceBootSession {
38
- sessionId: string;
39
- userId: string;
40
- accessToken: string;
41
- }
42
- /** Outcome of {@link consumeDeviceBootReturn}. */
43
- export type DeviceBootReturnOutcome = {
44
- kind: 'none';
45
- } | {
46
- kind: 'state-mismatch';
47
- } | {
48
- kind: 'session';
49
- session: DeviceBootSession;
50
- } | {
51
- kind: 'no-session';
52
- reason: DeviceBootReason;
53
- };
54
- export interface ConsumeDeviceBootReturnDeps {
55
- /** The current location hash (e.g. `window.location.hash`). */
56
- hash: string;
57
- /** Strip the fragment from the URL (e.g. `history.replaceState`). */
58
- stripFragment: () => void;
59
- /** Read the expected CSRF state (e.g. `sessionStorage.getItem(BOOT_STATE_SESSION_KEY)`). */
60
- readExpectedState: () => string | null;
61
- /** Clear the expected CSRF state (single-use). */
62
- clearExpectedState: () => void;
63
- store: AuthStateStore;
64
- /** Exchange the single-use boot code for a token bundle (`oxy.exchangeBootCode`). */
65
- exchangeBootCode: (code: string) => Promise<AuthTokenBundle>;
66
- /** Plant the freshly-minted access token on the owner client (`oxy.setTokens`). */
67
- plantAccessToken: (accessToken: string) => void;
68
- }
69
- /**
70
- * Consume the device-boot return fragment.
71
- *
72
- * Order is load-bearing:
73
- * 1. If no fragment is present, return `none` (no URL mutation).
74
- * 2. STRIP the fragment from the URL immediately — before validation or any
75
- * network — so the deviceToken/code never persist in history/referrer.
76
- * 3. Verify the echoed `state` against the stashed (single-use) value; a
77
- * mismatch returns `state-mismatch` without persisting or exchanging.
78
- * 4. Persist the deviceToken (survives sign-out).
79
- * 5. If a session resolved (`reason:'session'` + `code`), exchange the code,
80
- * persist the rotated session, plant the token, and return `session`.
81
- * Otherwise return `no-session` with the reason.
82
- */
83
- export declare function consumeDeviceBootReturn(deps: ConsumeDeviceBootReturnDeps): Promise<DeviceBootReturnOutcome>;
@@ -1,190 +0,0 @@
1
- import type { AuthTokenBundle } from '@oxyhq/contracts';
2
- import {
3
- parseDeviceBootFragment,
4
- hashHasBootFragment,
5
- consumeDeviceBootReturn,
6
- type ConsumeDeviceBootReturnDeps,
7
- } from '../deviceBootReturn';
8
- import { createMemoryAuthStateStore } from '../../session/authStateStore';
9
-
10
- const STATE = 'st-1234567890';
11
- const CODE = 'c'.repeat(24);
12
- const DEVICE_TOKEN = 'd'.repeat(24);
13
-
14
- // A loose record (fed to `encodeHash(unknown)` and validated by the schema) so
15
- // tests can build the session arm plus invalid variants (missing/extra `code`,
16
- // `v: 2`) without fighting the discriminated-union type.
17
- function fragmentObject(overrides: Record<string, unknown> = {}): Record<string, unknown> {
18
- return { v: 1, state: STATE, reason: 'session', code: CODE, deviceToken: DEVICE_TOKEN, ...overrides };
19
- }
20
-
21
- function encodeHash(obj: unknown): string {
22
- const b64 = Buffer.from(JSON.stringify(obj), 'utf-8')
23
- .toString('base64')
24
- .replace(/\+/g, '-')
25
- .replace(/\//g, '_')
26
- .replace(/=+$/, '');
27
- return `#oxy_boot=${b64}`;
28
- }
29
-
30
- const BUNDLE: AuthTokenBundle = {
31
- sessionId: 'sess-1',
32
- accessToken: 'access-jwt',
33
- refreshToken: 'refresh-abcdefghijklmnop',
34
- expiresAt: '2030-01-01T00:00:00.000Z',
35
- user: { id: 'user-1', name: {} } as AuthTokenBundle['user'],
36
- };
37
-
38
- /** Build the injectable deps around a memory store, recording call order. */
39
- function makeDeps(
40
- hash: string,
41
- overrides: Partial<ConsumeDeviceBootReturnDeps> & { expectedState?: string | null } = {},
42
- ): { deps: ConsumeDeviceBootReturnDeps; calls: string[]; store: ReturnType<typeof createMemoryAuthStateStore> } {
43
- const calls: string[] = [];
44
- const store = createMemoryAuthStateStore();
45
- const deps: ConsumeDeviceBootReturnDeps = {
46
- hash,
47
- stripFragment: () => calls.push('strip'),
48
- readExpectedState: () => (overrides.expectedState !== undefined ? overrides.expectedState : STATE),
49
- clearExpectedState: () => calls.push('clearState'),
50
- store,
51
- exchangeBootCode: overrides.exchangeBootCode
52
- ?? (async () => {
53
- calls.push('exchange');
54
- return BUNDLE;
55
- }),
56
- plantAccessToken: overrides.plantAccessToken ?? ((token) => calls.push(`plant:${token}`)),
57
- };
58
- return { deps, calls, store };
59
- }
60
-
61
- describe('parseDeviceBootFragment', () => {
62
- it('parses a valid base64url fragment', () => {
63
- expect(parseDeviceBootFragment(encodeHash(fragmentObject()))).toEqual(fragmentObject());
64
- });
65
-
66
- it('returns null when the parameter is absent', () => {
67
- expect(parseDeviceBootFragment('#something=else')).toBeNull();
68
- expect(parseDeviceBootFragment('')).toBeNull();
69
- });
70
-
71
- it('returns null for malformed base64 / non-JSON / wrong shape', () => {
72
- expect(parseDeviceBootFragment('#oxy_boot=!!!not-base64!!!')).toBeNull();
73
- expect(parseDeviceBootFragment(encodeHash({ nope: true }))).toBeNull();
74
- // v must be the literal 1
75
- expect(parseDeviceBootFragment(encodeHash(fragmentObject({ v: 2 })))).toBeNull();
76
- });
77
-
78
- it('returns null for a session fragment missing its code (discriminated union)', () => {
79
- expect(parseDeviceBootFragment(encodeHash(fragmentObject({ code: undefined })))).toBeNull();
80
- });
81
- });
82
-
83
- describe('hashHasBootFragment', () => {
84
- it('detects the fragment regardless of position', () => {
85
- expect(hashHasBootFragment('#oxy_boot=abc')).toBe(true);
86
- expect(hashHasBootFragment('#x=1&oxy_boot=abc')).toBe(true);
87
- expect(hashHasBootFragment('#other=1')).toBe(false);
88
- expect(hashHasBootFragment('')).toBe(false);
89
- });
90
- });
91
-
92
- describe('consumeDeviceBootReturn', () => {
93
- it('returns none and does not touch the URL when no fragment is present', async () => {
94
- const { deps, calls } = makeDeps('#unrelated=1');
95
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'none' });
96
- expect(calls).not.toContain('strip');
97
- });
98
-
99
- it('strips the fragment BEFORE exchanging the code', async () => {
100
- const { deps, calls } = makeDeps(encodeHash(fragmentObject()));
101
- const outcome = await consumeDeviceBootReturn(deps);
102
- expect(outcome.kind).toBe('session');
103
- expect(calls.indexOf('strip')).toBeLessThan(calls.indexOf('exchange'));
104
- });
105
-
106
- it('resolves a session, persists it, and plants the token', async () => {
107
- const { deps, store } = makeDeps(encodeHash(fragmentObject()));
108
- const outcome = await consumeDeviceBootReturn(deps);
109
- expect(outcome).toEqual({
110
- kind: 'session',
111
- session: { sessionId: 'sess-1', userId: 'user-1', accessToken: 'access-jwt' },
112
- });
113
- expect(await store.load()).toMatchObject({
114
- sessionId: 'sess-1',
115
- refreshToken: 'refresh-abcdefghijklmnop',
116
- userId: 'user-1',
117
- deviceToken: DEVICE_TOKEN,
118
- });
119
- expect(await store.loadDeviceToken()).toBe(DEVICE_TOKEN);
120
- });
121
-
122
- it('captures the bundle deviceSecret (phase 2c) and preserves a prior deviceId', async () => {
123
- const { deps, store } = makeDeps(encodeHash(fragmentObject()), {
124
- exchangeBootCode: async () => ({ ...BUNDLE, deviceSecret: 'ds-from-exchange' }),
125
- });
126
- // A prior deviceId-bearing login persisted a deviceId; the bundle carries no
127
- // deviceId, so the overwrite must preserve it to keep the mint lane usable.
128
- await store.save({ sessionId: 'prev', refreshToken: 'r-prevabcdefghij', userId: 'user-1', deviceId: 'dev-prev' });
129
-
130
- await consumeDeviceBootReturn(deps);
131
-
132
- const persisted = await store.load();
133
- expect(persisted?.deviceSecret).toBe('ds-from-exchange');
134
- expect(persisted?.deviceId).toBe('dev-prev');
135
- });
136
-
137
- it('preserves a prior deviceSecret when the bundle omits one (never orphans the mint lane)', async () => {
138
- const { deps, store } = makeDeps(encodeHash(fragmentObject()));
139
- await store.save({
140
- sessionId: 'prev',
141
- refreshToken: 'r-prevabcdefghij',
142
- userId: 'user-1',
143
- deviceId: 'dev-prev',
144
- deviceSecret: 'ds-still-valid',
145
- });
146
-
147
- await consumeDeviceBootReturn(deps);
148
-
149
- const persisted = await store.load();
150
- expect(persisted?.deviceSecret).toBe('ds-still-valid');
151
- expect(persisted?.deviceId).toBe('dev-prev');
152
- });
153
-
154
- it('rejects a state mismatch without persisting or exchanging', async () => {
155
- const { deps, calls, store } = makeDeps(encodeHash(fragmentObject()), { expectedState: 'WRONG' });
156
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'state-mismatch' });
157
- expect(calls).toContain('strip');
158
- expect(calls).not.toContain('exchange');
159
- expect(await store.loadDeviceToken()).toBeNull();
160
- });
161
-
162
- it('rejects when there is no expected state stashed at all', async () => {
163
- const { deps } = makeDeps(encodeHash(fragmentObject()), { expectedState: null });
164
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'state-mismatch' });
165
- });
166
-
167
- it('persists the deviceToken but returns no-session for a signed-out device', async () => {
168
- const { deps, store, calls } = makeDeps(
169
- encodeHash(fragmentObject({ reason: 'new_device', code: undefined })),
170
- );
171
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'no-session', reason: 'new_device' });
172
- expect(calls).not.toContain('exchange');
173
- expect(await store.loadDeviceToken()).toBe(DEVICE_TOKEN);
174
- });
175
-
176
- it('returns no-session when the code exchange fails (burned/expired code)', async () => {
177
- const { deps } = makeDeps(encodeHash(fragmentObject()), {
178
- exchangeBootCode: async () => {
179
- throw new Error('code already burned');
180
- },
181
- });
182
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'no-session', reason: 'no_session' });
183
- });
184
-
185
- it('strips even a malformed fragment and returns none', async () => {
186
- const { deps, calls } = makeDeps('#oxy_boot=!!!malformed!!!');
187
- expect(await consumeDeviceBootReturn(deps)).toEqual({ kind: 'none' });
188
- expect(calls).toContain('strip');
189
- });
190
- });
@@ -1,210 +0,0 @@
1
- /**
2
- * Device-boot return-fragment consumption (web cross-apex hop).
3
- *
4
- * After the top-level `GET /auth/device/bootstrap` hop, the API 303s back to the
5
- * RP with a `#oxy_boot=<base64url(JSON)>` fragment. This module parses and
6
- * consumes it: it strips the fragment from the URL FIRST (so the opaque
7
- * deviceToken / code never linger in history or a `Referer`), verifies the
8
- * echoed CSRF `state` against the value the initiator stashed in
9
- * `sessionStorage`, persists the deviceToken, and — when a session resolved —
10
- * exchanges the single-use `code` for a token bundle.
11
- *
12
- * Pure/injectable: all DOM access (hash, `history.replaceState`,
13
- * `sessionStorage`) is passed in as callbacks so the logic is unit-testable
14
- * under the jest `node` environment and reusable by `coldBootV2`.
15
- *
16
- * ESM-safe (no `require()`).
17
- */
18
- import {
19
- deviceBootFragmentSchema,
20
- resolveUserId,
21
- safeParseContract,
22
- type AuthTokenBundle,
23
- type DeviceBootFragment,
24
- type DeviceBootReason,
25
- } from '@oxyhq/contracts';
26
- import type { AuthStateStore, PersistedAuthState } from '../session/authStateStore';
27
-
28
- /** The `#oxy_boot=` fragment parameter name the API appends on the return hop. */
29
- export const BOOT_FRAGMENT_PARAM = 'oxy_boot';
30
-
31
- /**
32
- * `sessionStorage` key under which the bootstrap-hop initiator stashes the
33
- * 128-bit CSRF `state` before navigating, and which the return step reads back
34
- * (single-use).
35
- */
36
- export const BOOT_STATE_SESSION_KEY = 'oxy.boot.state';
37
-
38
- /**
39
- * Decode a base64url string to UTF-8 text, or `null` on any malformed input.
40
- * Handles both web (`atob` + `TextDecoder`) and Node (`Buffer`) without a
41
- * `require()` — the ESM build stays clean.
42
- */
43
- function base64UrlDecode(input: string): string | null {
44
- try {
45
- let b64 = input.replace(/-/g, '+').replace(/_/g, '/');
46
- while (b64.length % 4 !== 0) {
47
- b64 += '=';
48
- }
49
- if (typeof atob === 'function') {
50
- const binary = atob(b64);
51
- const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
52
- if (typeof TextDecoder !== 'undefined') {
53
- return new TextDecoder().decode(bytes);
54
- }
55
- return binary;
56
- }
57
- if (typeof Buffer !== 'undefined') {
58
- return Buffer.from(b64, 'base64').toString('utf-8');
59
- }
60
- return null;
61
- } catch {
62
- return null;
63
- }
64
- }
65
-
66
- /** True when a location hash carries the `oxy_boot` return fragment. */
67
- export function hashHasBootFragment(hash: string): boolean {
68
- return new RegExp(`(^|[#&])${BOOT_FRAGMENT_PARAM}=`).test(hash);
69
- }
70
-
71
- /**
72
- * Extract + decode + validate the `oxy_boot` fragment from a location hash.
73
- * Returns the parsed {@link DeviceBootFragment}, or `null` when the parameter
74
- * is absent, not valid base64url, not JSON, or fails the contract schema.
75
- */
76
- export function parseDeviceBootFragment(hash: string): DeviceBootFragment | null {
77
- const withoutHash = hash.startsWith('#') ? hash.slice(1) : hash;
78
- const params = new URLSearchParams(withoutHash);
79
- const raw = params.get(BOOT_FRAGMENT_PARAM);
80
- if (!raw) {
81
- return null;
82
- }
83
- const json = base64UrlDecode(raw);
84
- if (!json) {
85
- return null;
86
- }
87
- let parsed: unknown;
88
- try {
89
- parsed = JSON.parse(json);
90
- } catch {
91
- return null;
92
- }
93
- return safeParseContract(deviceBootFragmentSchema, parsed);
94
- }
95
-
96
- /** The winning session shape a cold-boot step reports. */
97
- export interface DeviceBootSession {
98
- sessionId: string;
99
- userId: string;
100
- accessToken: string;
101
- }
102
-
103
- /** Outcome of {@link consumeDeviceBootReturn}. */
104
- export type DeviceBootReturnOutcome =
105
- | { kind: 'none' }
106
- | { kind: 'state-mismatch' }
107
- | { kind: 'session'; session: DeviceBootSession }
108
- | { kind: 'no-session'; reason: DeviceBootReason };
109
-
110
- export interface ConsumeDeviceBootReturnDeps {
111
- /** The current location hash (e.g. `window.location.hash`). */
112
- hash: string;
113
- /** Strip the fragment from the URL (e.g. `history.replaceState`). */
114
- stripFragment: () => void;
115
- /** Read the expected CSRF state (e.g. `sessionStorage.getItem(BOOT_STATE_SESSION_KEY)`). */
116
- readExpectedState: () => string | null;
117
- /** Clear the expected CSRF state (single-use). */
118
- clearExpectedState: () => void;
119
- store: AuthStateStore;
120
- /** Exchange the single-use boot code for a token bundle (`oxy.exchangeBootCode`). */
121
- exchangeBootCode: (code: string) => Promise<AuthTokenBundle>;
122
- /** Plant the freshly-minted access token on the owner client (`oxy.setTokens`). */
123
- plantAccessToken: (accessToken: string) => void;
124
- }
125
-
126
- /**
127
- * Consume the device-boot return fragment.
128
- *
129
- * Order is load-bearing:
130
- * 1. If no fragment is present, return `none` (no URL mutation).
131
- * 2. STRIP the fragment from the URL immediately — before validation or any
132
- * network — so the deviceToken/code never persist in history/referrer.
133
- * 3. Verify the echoed `state` against the stashed (single-use) value; a
134
- * mismatch returns `state-mismatch` without persisting or exchanging.
135
- * 4. Persist the deviceToken (survives sign-out).
136
- * 5. If a session resolved (`reason:'session'` + `code`), exchange the code,
137
- * persist the rotated session, plant the token, and return `session`.
138
- * Otherwise return `no-session` with the reason.
139
- */
140
- export async function consumeDeviceBootReturn(
141
- deps: ConsumeDeviceBootReturnDeps,
142
- ): Promise<DeviceBootReturnOutcome> {
143
- if (!hashHasBootFragment(deps.hash)) {
144
- return { kind: 'none' };
145
- }
146
-
147
- // Strip FIRST — even a forged/malformed fragment must not linger in the URL.
148
- deps.stripFragment();
149
-
150
- const fragment = parseDeviceBootFragment(deps.hash);
151
- if (!fragment) {
152
- return { kind: 'none' };
153
- }
154
-
155
- const expected = deps.readExpectedState();
156
- deps.clearExpectedState();
157
- if (!expected || expected !== fragment.state) {
158
- return { kind: 'state-mismatch' };
159
- }
160
-
161
- await deps.store.saveDeviceToken(fragment.deviceToken);
162
-
163
- // `code` is guaranteed present on the `session` arm (the contract's
164
- // discriminated union requires it; a session fragment without a code fails to
165
- // parse and never reaches here).
166
- if (fragment.reason === 'session') {
167
- try {
168
- const bundle = await deps.exchangeBootCode(fragment.code);
169
- const userId = resolveUserId(bundle.user);
170
- if (!userId) {
171
- return { kind: 'no-session', reason: 'no_session' };
172
- }
173
- const next: PersistedAuthState = {
174
- sessionId: bundle.sessionId,
175
- refreshToken: bundle.refreshToken,
176
- userId,
177
- deviceToken: fragment.deviceToken,
178
- accessToken: bundle.accessToken,
179
- expiresAt: bundle.expiresAt,
180
- };
181
- // Phase 2c: the cookie-bootstrap bundle may carry a rotating `deviceSecret`
182
- // but NOT a deviceId. Persist the secret, and carry any prior deviceId
183
- // forward (from a deviceId-bearing login lane) so the pair stays usable by
184
- // the zero-cookie mint — an overwrite here must not orphan the mint lane.
185
- const prior = await deps.store.load();
186
- if (prior?.deviceId) {
187
- next.deviceId = prior.deviceId;
188
- }
189
- // Prefer the bundle's secret (the server just rotated onto it); keep the
190
- // prior one when the bundle omits it so a cookie-lane boot can never orphan
191
- // a still-valid secret captured by an earlier login lane.
192
- const carriedSecret = bundle.deviceSecret ?? prior?.deviceSecret;
193
- if (carriedSecret) {
194
- next.deviceSecret = carriedSecret;
195
- }
196
- await deps.store.save(next);
197
- deps.plantAccessToken(bundle.accessToken);
198
- return {
199
- kind: 'session',
200
- session: { sessionId: bundle.sessionId, userId, accessToken: bundle.accessToken },
201
- };
202
- } catch {
203
- // The code burned/expired between hop and exchange — resolve signed-out
204
- // rather than throwing (the once-ever hop already fired; do not retry).
205
- return { kind: 'no-session', reason: 'no_session' };
206
- }
207
- }
208
-
209
- return { kind: 'no-session', reason: fragment.reason };
210
- }
@@ -1,24 +0,0 @@
1
- /**
2
- * Shared device-token contract on web. Under the jest `node` environment
3
- * `getPlatformOS()` resolves to `'web'`, so the shared-keychain device-token
4
- * methods are no-ops (web persists its deviceToken in the per-origin
5
- * AuthStateStore instead of a keychain). The native keychain plumbing is
6
- * exercised via spies in the coldBootV2 suite.
7
- */
8
- import { KeyManager } from '../keyManager';
9
-
10
- describe('KeyManager shared device token (web)', () => {
11
- it('getSharedDeviceToken returns null on web', async () => {
12
- expect(await KeyManager.getSharedDeviceToken()).toBeNull();
13
- });
14
-
15
- it('setSharedDeviceToken is a no-op that does not throw on web', async () => {
16
- await expect(KeyManager.setSharedDeviceToken('dt-web')).resolves.toBeUndefined();
17
- // Still null — nothing was persisted on web.
18
- expect(await KeyManager.getSharedDeviceToken()).toBeNull();
19
- });
20
-
21
- it('clearSharedDeviceToken is a no-op that does not throw on web', async () => {
22
- await expect(KeyManager.clearSharedDeviceToken()).resolves.toBeUndefined();
23
- });
24
- });