@oxyhq/core 8.1.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/coldBootV2.js +22 -332
- package/dist/cjs/crypto/keyManager.js +0 -95
- package/dist/cjs/index.js +6 -19
- package/dist/cjs/mixins/OxyServices.auth.js +4 -7
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +19 -115
- package/dist/cjs/mixins/index.js +2 -3
- package/dist/cjs/session/SessionClient.js +29 -100
- package/dist/cjs/session/accountDialogController.js +0 -13
- package/dist/cjs/session/authStateStore.js +9 -89
- package/dist/cjs/session/createSessionClient.js +2 -9
- package/dist/cjs/session/refresh.js +46 -71
- package/dist/cjs/utils/registrableApex.js +2 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +23 -330
- package/dist/esm/crypto/keyManager.js +0 -95
- package/dist/esm/index.js +7 -11
- package/dist/esm/mixins/OxyServices.auth.js +4 -7
- package/dist/esm/mixins/OxyServices.deviceBoot.js +20 -116
- package/dist/esm/mixins/index.js +2 -3
- package/dist/esm/session/SessionClient.js +29 -100
- package/dist/esm/session/accountDialogController.js +0 -13
- package/dist/esm/session/authStateStore.js +8 -88
- package/dist/esm/session/createSessionClient.js +2 -9
- package/dist/esm/session/refresh.js +46 -71
- package/dist/esm/utils/registrableApex.js +2 -6
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -3
- package/dist/types/boot/coldBootV2.d.ts +28 -53
- package/dist/types/crypto/keyManager.d.ts +0 -21
- package/dist/types/index.d.ts +3 -5
- package/dist/types/mixins/OxyServices.auth.d.ts +4 -7
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +20 -60
- package/dist/types/session/SessionClient.d.ts +4 -38
- package/dist/types/session/accountDialogController.d.ts +1 -3
- package/dist/types/session/authStateStore.d.ts +32 -57
- package/dist/types/session/createSessionClient.d.ts +2 -9
- package/dist/types/session/refresh.d.ts +32 -28
- package/dist/types/utils/registrableApex.d.ts +2 -6
- package/package.json +2 -2
- package/src/HttpService.ts +3 -3
- package/src/boot/__tests__/coldBootV2.test.ts +140 -353
- package/src/boot/coldBootV2.ts +35 -391
- package/src/crypto/keyManager.ts +0 -101
- package/src/index.ts +7 -30
- package/src/mixins/OxyServices.auth.ts +5 -9
- package/src/mixins/OxyServices.deviceBoot.ts +19 -142
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +9 -96
- package/src/mixins/__tests__/onTokensChanged.test.ts +0 -1
- package/src/mixins/__tests__/passwordSignIn.test.ts +33 -9
- package/src/mixins/index.ts +2 -3
- package/src/session/SessionClient.ts +29 -120
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +113 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +8 -8
- package/src/session/__tests__/authStateStore.test.ts +29 -42
- package/src/session/__tests__/refresh.test.ts +66 -52
- package/src/session/accountDialogController.ts +4 -18
- package/src/session/authStateStore.ts +32 -126
- package/src/session/createSessionClient.ts +2 -9
- package/src/session/refresh.ts +60 -92
- package/src/utils/registrableApex.ts +2 -6
- package/dist/cjs/boot/deviceBootReturn.js +0 -167
- package/dist/esm/boot/deviceBootReturn.js +0 -161
- package/dist/types/boot/deviceBootReturn.d.ts +0 -83
- package/src/boot/__tests__/deviceBootReturn.test.ts +0 -190
- package/src/boot/deviceBootReturn.ts +0 -210
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
- 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
|
-
});
|