@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
package/src/index.ts
CHANGED
|
@@ -582,18 +582,17 @@ export type {
|
|
|
582
582
|
} from './session/accountDialogController';
|
|
583
583
|
|
|
584
584
|
// ---------------------------------------------------------------------------
|
|
585
|
-
// Device-first session machinery (
|
|
586
|
-
// Persisted auth-state store, the unified
|
|
587
|
-
// cold-boot
|
|
588
|
-
//
|
|
589
|
-
//
|
|
585
|
+
// Device-first session machinery (zero-cookie transport).
|
|
586
|
+
// Persisted auth-state store, the unified re-mint handler + scheduler, and the
|
|
587
|
+
// cold-boot runner. Built ON the `runColdBoot` primitive + `SessionClient`. The
|
|
588
|
+
// device credential is `deviceId` + `deviceSecret`; the access token is re-minted
|
|
589
|
+
// via `POST /session/device/token`.
|
|
590
590
|
// ---------------------------------------------------------------------------
|
|
591
591
|
export {
|
|
592
592
|
createWebAuthStateStore,
|
|
593
593
|
createNativeAuthStateStore,
|
|
594
594
|
createMemoryAuthStateStore,
|
|
595
595
|
AUTH_STATE_STORAGE_KEY,
|
|
596
|
-
DEVICE_TOKEN_STORAGE_KEY,
|
|
597
596
|
} from './session/authStateStore';
|
|
598
597
|
export type {
|
|
599
598
|
PersistedAuthState,
|
|
@@ -610,34 +609,12 @@ export {
|
|
|
610
609
|
} from './session/refresh';
|
|
611
610
|
export type { RefreshDeps, TokenRefreshSchedulerHandle } from './session/refresh';
|
|
612
611
|
|
|
613
|
-
export {
|
|
614
|
-
runSessionColdBoot,
|
|
615
|
-
createBrowserColdBootDom,
|
|
616
|
-
isSameApex,
|
|
617
|
-
BOOT_ATTEMPTED_KEY,
|
|
618
|
-
} from './boot/coldBootV2';
|
|
612
|
+
export { runSessionColdBoot } from './boot/coldBootV2';
|
|
619
613
|
export type {
|
|
620
614
|
RunSessionColdBootOptions,
|
|
621
|
-
ColdBootDom,
|
|
622
615
|
SignedOutReason,
|
|
623
|
-
} from './boot/coldBootV2';
|
|
624
|
-
|
|
625
|
-
export {
|
|
626
|
-
consumeDeviceBootReturn,
|
|
627
|
-
parseDeviceBootFragment,
|
|
628
|
-
hashHasBootFragment,
|
|
629
|
-
BOOT_FRAGMENT_PARAM,
|
|
630
|
-
BOOT_STATE_SESSION_KEY,
|
|
631
|
-
} from './boot/deviceBootReturn';
|
|
632
|
-
export type {
|
|
633
616
|
DeviceBootSession,
|
|
634
|
-
|
|
635
|
-
ConsumeDeviceBootReturnDeps,
|
|
636
|
-
} from './boot/deviceBootReturn';
|
|
637
|
-
|
|
638
|
-
// The web-session result contract (`WebSessionResult`) is owned by
|
|
639
|
-
// `@oxyhq/contracts` — import it directly from there; `@oxyhq/core` does not
|
|
640
|
-
// re-export contract types.
|
|
617
|
+
} from './boot/coldBootV2';
|
|
641
618
|
|
|
642
619
|
// API response contracts (request/response Zod schemas + inferred types) live in
|
|
643
620
|
// `@oxyhq/contracts` — the single source of truth shared by the backend and every
|
|
@@ -1128,16 +1128,15 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1128
1128
|
* (`{ twoFactorRequired, loginToken }`) to complete via
|
|
1129
1129
|
* {@link completeTwoFactorSignIn}, or a session arm.
|
|
1130
1130
|
*
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
* caller has an authenticated client without a second round-trip.
|
|
1131
|
+
* On the session arm, a returned access token is planted immediately
|
|
1132
|
+
* (mirroring {@link verifyChallenge}), so the caller has an authenticated
|
|
1133
|
+
* client without a second round-trip. The response's `deviceId` +
|
|
1134
|
+
* `deviceSecret` are the zero-cookie restore credential the caller persists.
|
|
1136
1135
|
*/
|
|
1137
1136
|
async passwordSignIn(
|
|
1138
1137
|
identifier: string,
|
|
1139
1138
|
password: string,
|
|
1140
|
-
options: {
|
|
1139
|
+
options: { deviceName?: string; deviceFingerprint?: string } = {},
|
|
1141
1140
|
): Promise<LoginResult> {
|
|
1142
1141
|
try {
|
|
1143
1142
|
const res = await this.makeRequest<unknown>('POST', '/auth/login', {
|
|
@@ -1145,7 +1144,6 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1145
1144
|
password,
|
|
1146
1145
|
deviceName: options.deviceName,
|
|
1147
1146
|
deviceFingerprint: options.deviceFingerprint,
|
|
1148
|
-
deviceToken: options.deviceToken,
|
|
1149
1147
|
}, { cache: false });
|
|
1150
1148
|
const parsed = safeParseContract(loginResultSchema, res);
|
|
1151
1149
|
if (!parsed) {
|
|
@@ -1171,7 +1169,6 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1171
1169
|
loginToken: string;
|
|
1172
1170
|
token?: string;
|
|
1173
1171
|
backupCode?: string;
|
|
1174
|
-
deviceToken?: string;
|
|
1175
1172
|
deviceName?: string;
|
|
1176
1173
|
}): Promise<LoginSessionResult> {
|
|
1177
1174
|
try {
|
|
@@ -1179,7 +1176,6 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
1179
1176
|
loginToken: params.loginToken,
|
|
1180
1177
|
token: params.token,
|
|
1181
1178
|
backupCode: params.backupCode,
|
|
1182
|
-
deviceToken: params.deviceToken,
|
|
1183
1179
|
deviceName: params.deviceName,
|
|
1184
1180
|
}, { cache: false });
|
|
1185
1181
|
const parsed = safeParseContract(loginResultSchema, res);
|
|
@@ -1,151 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Device-first
|
|
2
|
+
* Device-first token mint mixin.
|
|
3
3
|
*
|
|
4
|
-
* The client half of the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The client half of the zero-cookie device transport: the single network call
|
|
5
|
+
* the cold boot (`coldBootV2`) and the unified re-mint handler (`refresh.ts`)
|
|
6
|
+
* make to turn a first-party `deviceId` + `deviceSecret` into a fresh access
|
|
7
|
+
* token. The response is validated against the `@oxyhq/contracts`
|
|
8
|
+
* `deviceTokenMintResponseSchema`, so producer (oxy-api) and consumer cannot
|
|
9
9
|
* drift — an unexpected shape throws here rather than silently corrupting the
|
|
10
10
|
* persisted store.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* and `setTokens`, so the same network primitive can be reused from either
|
|
16
|
-
* without double-planting.
|
|
12
|
+
* This method carries NO persistence or token-planting side effects of its own;
|
|
13
|
+
* the cold boot / re-mint handler own persistence and `setTokens`, so the same
|
|
14
|
+
* primitive can be reused from either without double-planting.
|
|
17
15
|
*/
|
|
18
16
|
import {
|
|
19
|
-
authTokenBundleSchema,
|
|
20
|
-
tokenRefreshResponseSchema,
|
|
21
|
-
deviceTokenIssueResponseSchema,
|
|
22
17
|
deviceTokenMintResponseSchema,
|
|
23
|
-
webSessionResultSchema,
|
|
24
18
|
safeParseContract,
|
|
25
|
-
type AuthTokenBundle,
|
|
26
19
|
type DeviceTokenMintResponse,
|
|
27
|
-
type TokenRefreshResponse,
|
|
28
|
-
type WebSessionResult,
|
|
29
20
|
} from '@oxyhq/contracts';
|
|
30
21
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
31
22
|
|
|
32
23
|
export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
33
24
|
return class extends Base {
|
|
34
25
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
26
|
+
* Zero-cookie mint. Present the first-party `deviceId` + `deviceSecret` to
|
|
27
|
+
* `POST /session/device/token` — NO bearer, NO cookies: possession of the
|
|
28
|
+
* secret IS the device-ownership proof. Returns a fresh short access token
|
|
29
|
+
* for the device's active account plus `nextDeviceSecret` (rotation-in-use)
|
|
30
|
+
* and the projected device-session `state`.
|
|
37
31
|
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'POST',
|
|
44
|
-
'/auth/device/exchange',
|
|
45
|
-
{ code },
|
|
46
|
-
{ cache: false },
|
|
47
|
-
);
|
|
48
|
-
const parsed = safeParseContract(authTokenBundleSchema, res);
|
|
49
|
-
if (!parsed) {
|
|
50
|
-
throw new Error('device/exchange returned an unexpected response shape');
|
|
51
|
-
}
|
|
52
|
-
return parsed;
|
|
53
|
-
} catch (error) {
|
|
54
|
-
throw this.handleError(error);
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Same-site fast path (`*.oxy.so` apps). Reads the first-party `oxy_device`
|
|
60
|
-
* cookie via a credentialed same-origin fetch and either resolves a full
|
|
61
|
-
* session bundle or reports the device is known-but-signed-out. Never
|
|
62
|
-
* redirects.
|
|
63
|
-
*
|
|
64
|
-
* @throws if the response matches neither arm of {@link webSessionResultSchema}.
|
|
65
|
-
*/
|
|
66
|
-
async requestWebSession(): Promise<WebSessionResult> {
|
|
67
|
-
try {
|
|
68
|
-
const res = await this.makeRequest<unknown>(
|
|
69
|
-
'POST',
|
|
70
|
-
'/auth/device/web-session',
|
|
71
|
-
undefined,
|
|
72
|
-
{ cache: false },
|
|
73
|
-
);
|
|
74
|
-
const parsed = safeParseContract(webSessionResultSchema, res);
|
|
75
|
-
if (!parsed) {
|
|
76
|
-
throw new Error('device/web-session returned an unexpected response shape');
|
|
77
|
-
}
|
|
78
|
-
return parsed;
|
|
79
|
-
} catch (error) {
|
|
80
|
-
throw this.handleError(error);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Rotate the persisted refresh-token family (web + native, one call). The
|
|
86
|
-
* caller (refresh handler / cold boot) plants + persists the rotated pair.
|
|
87
|
-
*
|
|
88
|
-
* @throws if the response does not match {@link tokenRefreshResponseSchema}.
|
|
89
|
-
*/
|
|
90
|
-
async refreshWithToken(refreshToken: string): Promise<TokenRefreshResponse> {
|
|
91
|
-
try {
|
|
92
|
-
const res = await this.makeRequest<unknown>(
|
|
93
|
-
'POST',
|
|
94
|
-
'/auth/refresh-token',
|
|
95
|
-
{ refreshToken },
|
|
96
|
-
// `skipAuth`: refresh-token is body-authenticated and is called from
|
|
97
|
-
// inside the refresh handler — sending the near-expired bearer through
|
|
98
|
-
// the preflight would deadlock. See RequestOptions.skipAuth.
|
|
99
|
-
{ cache: false, skipAuth: true },
|
|
100
|
-
);
|
|
101
|
-
const parsed = safeParseContract(tokenRefreshResponseSchema, res);
|
|
102
|
-
if (!parsed) {
|
|
103
|
-
throw new Error('auth/refresh-token returned an unexpected response shape');
|
|
104
|
-
}
|
|
105
|
-
return parsed;
|
|
106
|
-
} catch (error) {
|
|
107
|
-
throw this.handleError(error);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Issue (or rotate) the native channel's opaque device token. Bearer-gated —
|
|
113
|
-
* the server derives the deviceId from the JWT. Native apps mirror the
|
|
114
|
-
* returned token into the shared keychain via
|
|
115
|
-
* `KeyManager.setSharedDeviceToken`.
|
|
116
|
-
*
|
|
117
|
-
* @throws if the response does not match {@link deviceTokenIssueResponseSchema}.
|
|
118
|
-
*/
|
|
119
|
-
async issueNativeDeviceToken(): Promise<string> {
|
|
120
|
-
try {
|
|
121
|
-
const res = await this.makeRequest<unknown>(
|
|
122
|
-
'POST',
|
|
123
|
-
'/auth/device/token',
|
|
124
|
-
undefined,
|
|
125
|
-
{ cache: false },
|
|
126
|
-
);
|
|
127
|
-
const parsed = safeParseContract(deviceTokenIssueResponseSchema, res);
|
|
128
|
-
if (!parsed) {
|
|
129
|
-
throw new Error('auth/device/token returned an unexpected response shape');
|
|
130
|
-
}
|
|
131
|
-
return parsed.deviceToken;
|
|
132
|
-
} catch (error) {
|
|
133
|
-
throw this.handleError(error);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Zero-cookie mint (phase 2c). Present the first-party `deviceId` +
|
|
139
|
-
* `deviceSecret` to `POST /session/device/token` — NO bearer, NO cookies:
|
|
140
|
-
* possession of the secret IS the device-ownership proof. Returns a fresh
|
|
141
|
-
* short access token for the device's active account plus `nextDeviceSecret`
|
|
142
|
-
* (rotation-in-use) and the projected device-session `state`.
|
|
143
|
-
*
|
|
144
|
-
* `skipAuth` (like {@link refreshWithToken}): this call carries no bearer, so
|
|
145
|
-
* a 401 must surface DIRECTLY — never trigger `HttpService`'s 401→refresh→
|
|
146
|
-
* retry dance (which would pointlessly rotate the refresh family). The cold
|
|
147
|
-
* boot reads the 401 body (`invalid_device_secret` vs `no_active_session`) to
|
|
148
|
-
* decide whether to drop the secret and fall back or resolve signed-out.
|
|
32
|
+
* `skipAuth`: this call carries no bearer, so a 401 must surface DIRECTLY —
|
|
33
|
+
* never trigger `HttpService`'s 401→refresh→retry dance. The cold boot / re-
|
|
34
|
+
* mint handler read the 401 body (`invalid_device_secret` vs
|
|
35
|
+
* `no_active_session`) to decide whether to drop the secret and fall back or
|
|
36
|
+
* resolve signed-out.
|
|
149
37
|
*
|
|
150
38
|
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
151
39
|
*/
|
|
@@ -169,16 +57,5 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
169
57
|
throw this.handleError(error);
|
|
170
58
|
}
|
|
171
59
|
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Build the top-level `GET /auth/device/bootstrap` URL for the cross-apex
|
|
175
|
-
* hop. The server validates `return_to` against the trusted-origin lane and
|
|
176
|
-
* echoes `state` back in the return fragment for CSRF verification.
|
|
177
|
-
*/
|
|
178
|
-
buildBootstrapUrl(returnTo: string, state: string): string {
|
|
179
|
-
const base = this.getBaseURL().replace(/\/+$/, '');
|
|
180
|
-
const params = new URLSearchParams({ return_to: returnTo, state });
|
|
181
|
-
return `${base}/auth/device/bootstrap?${params.toString()}`;
|
|
182
|
-
}
|
|
183
60
|
};
|
|
184
61
|
}
|
|
@@ -1,31 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Device-boot mixin tests. Stubs `makeRequest` so the tests run with no network
|
|
3
|
-
* and asserts
|
|
4
|
-
* `skipAuth` flag on the
|
|
3
|
+
* and asserts `mintFromDeviceSecret`'s route/shape, contract validation, and the
|
|
4
|
+
* `skipAuth` flag on the bearer-less mint call.
|
|
5
5
|
*/
|
|
6
|
-
import type {
|
|
7
|
-
AuthTokenBundle,
|
|
8
|
-
DeviceTokenMintResponse,
|
|
9
|
-
TokenRefreshResponse,
|
|
10
|
-
WebSessionResult,
|
|
11
|
-
} from '@oxyhq/contracts';
|
|
6
|
+
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
12
7
|
import { OxyServices } from '../../OxyServices';
|
|
13
8
|
|
|
14
|
-
const BUNDLE: AuthTokenBundle = {
|
|
15
|
-
sessionId: 'sess-1',
|
|
16
|
-
accessToken: 'access-1',
|
|
17
|
-
refreshToken: 'refresh-abcdefghijkl',
|
|
18
|
-
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
19
|
-
user: { id: 'user-1', name: {} } as AuthTokenBundle['user'],
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const ROTATED: TokenRefreshResponse = {
|
|
23
|
-
accessToken: 'access-2',
|
|
24
|
-
refreshToken: 'refresh-mnopqrstuvwx',
|
|
25
|
-
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
26
|
-
sessionId: 'sess-1',
|
|
27
|
-
};
|
|
28
|
-
|
|
29
9
|
describe('OxyServices.deviceBoot', () => {
|
|
30
10
|
let oxy: OxyServices;
|
|
31
11
|
let makeRequest: jest.SpyInstance;
|
|
@@ -37,70 +17,6 @@ describe('OxyServices.deviceBoot', () => {
|
|
|
37
17
|
|
|
38
18
|
afterEach(() => jest.restoreAllMocks());
|
|
39
19
|
|
|
40
|
-
describe('exchangeBootCode', () => {
|
|
41
|
-
it('POSTs the code and returns the validated bundle', async () => {
|
|
42
|
-
makeRequest.mockResolvedValueOnce(BUNDLE);
|
|
43
|
-
const result = await oxy.exchangeBootCode('code-123');
|
|
44
|
-
expect(result).toEqual(BUNDLE);
|
|
45
|
-
expect(makeRequest).toHaveBeenCalledWith('POST', '/auth/device/exchange', { code: 'code-123' }, { cache: false });
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('throws on an unexpected response shape', async () => {
|
|
49
|
-
makeRequest.mockResolvedValueOnce({ nope: true });
|
|
50
|
-
await expect(oxy.exchangeBootCode('code-123')).rejects.toThrow();
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe('requestWebSession', () => {
|
|
55
|
-
it('returns the session arm (bundle nested under `session` + deviceToken)', async () => {
|
|
56
|
-
const sessionArm: WebSessionResult = { reason: 'session', session: BUNDLE, deviceToken: 'device-abcdefghij' };
|
|
57
|
-
makeRequest.mockResolvedValueOnce(sessionArm);
|
|
58
|
-
const result = await oxy.requestWebSession();
|
|
59
|
-
expect(result).toEqual(sessionArm);
|
|
60
|
-
expect(makeRequest).toHaveBeenCalledWith('POST', '/auth/device/web-session', undefined, { cache: false });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('returns the no-session arm', async () => {
|
|
64
|
-
const noSession: WebSessionResult = { reason: 'no_session', deviceToken: 'device-abcdefghij' };
|
|
65
|
-
makeRequest.mockResolvedValueOnce(noSession);
|
|
66
|
-
const result = await oxy.requestWebSession();
|
|
67
|
-
expect(result.reason).toBe('no_session');
|
|
68
|
-
expect(result).toEqual(noSession);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('throws on the OLD bare-bundle shape (regression: server now wraps in a reason envelope)', async () => {
|
|
72
|
-
makeRequest.mockResolvedValueOnce(BUNDLE);
|
|
73
|
-
await expect(oxy.requestWebSession()).rejects.toThrow();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('throws when the response matches neither arm', async () => {
|
|
77
|
-
makeRequest.mockResolvedValueOnce({ reason: 'weird' });
|
|
78
|
-
await expect(oxy.requestWebSession()).rejects.toThrow();
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe('refreshWithToken', () => {
|
|
83
|
-
it('POSTs with skipAuth and returns the rotated pair', async () => {
|
|
84
|
-
makeRequest.mockResolvedValueOnce(ROTATED);
|
|
85
|
-
const result = await oxy.refreshWithToken('refresh-abcdefghijkl');
|
|
86
|
-
expect(result).toEqual(ROTATED);
|
|
87
|
-
expect(makeRequest).toHaveBeenCalledWith(
|
|
88
|
-
'POST',
|
|
89
|
-
'/auth/refresh-token',
|
|
90
|
-
{ refreshToken: 'refresh-abcdefghijkl' },
|
|
91
|
-
{ cache: false, skipAuth: true },
|
|
92
|
-
);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe('issueNativeDeviceToken', () => {
|
|
97
|
-
it('POSTs and returns just the token string', async () => {
|
|
98
|
-
makeRequest.mockResolvedValueOnce({ deviceToken: 'native-dt' });
|
|
99
|
-
expect(await oxy.issueNativeDeviceToken()).toBe('native-dt');
|
|
100
|
-
expect(makeRequest).toHaveBeenCalledWith('POST', '/auth/device/token', undefined, { cache: false });
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
20
|
describe('mintFromDeviceSecret', () => {
|
|
105
21
|
const MINT: DeviceTokenMintResponse = {
|
|
106
22
|
accessToken: 'access-minted',
|
|
@@ -115,7 +31,7 @@ describe('OxyServices.deviceBoot', () => {
|
|
|
115
31
|
},
|
|
116
32
|
};
|
|
117
33
|
|
|
118
|
-
it('POSTs deviceId + deviceSecret with skipAuth and returns the validated mint', async () => {
|
|
34
|
+
it('POSTs deviceId + deviceSecret with skipAuth (no bearer, no cache) and returns the validated mint', async () => {
|
|
119
35
|
makeRequest.mockResolvedValueOnce(MINT);
|
|
120
36
|
const result = await oxy.mintFromDeviceSecret('dev-1', 'ds-current-secret');
|
|
121
37
|
expect(result).toEqual(MINT);
|
|
@@ -127,7 +43,7 @@ describe('OxyServices.deviceBoot', () => {
|
|
|
127
43
|
);
|
|
128
44
|
});
|
|
129
45
|
|
|
130
|
-
it('throws on an unexpected response shape', async () => {
|
|
46
|
+
it('throws on an unexpected response shape (missing state / nextDeviceSecret)', async () => {
|
|
131
47
|
makeRequest.mockResolvedValueOnce({ accessToken: 'a', expiresAt: 'b' });
|
|
132
48
|
await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow();
|
|
133
49
|
});
|
|
@@ -137,14 +53,11 @@ describe('OxyServices.deviceBoot', () => {
|
|
|
137
53
|
makeRequest.mockRejectedValueOnce(err);
|
|
138
54
|
await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow('invalid_device_secret');
|
|
139
55
|
});
|
|
140
|
-
});
|
|
141
56
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
expect(
|
|
146
|
-
expect(url).toContain('return_to=https%3A%2F%2Faccounts.oxy.so%2Fhome');
|
|
147
|
-
expect(url).toContain('state=st-1');
|
|
57
|
+
it('propagates a 401 no_active_session so the caller can resolve signed-out', async () => {
|
|
58
|
+
const err = Object.assign(new Error('no_active_session'), { status: 401 });
|
|
59
|
+
makeRequest.mockRejectedValueOnce(err);
|
|
60
|
+
await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow('no_active_session');
|
|
148
61
|
});
|
|
149
62
|
});
|
|
150
63
|
});
|
|
@@ -115,7 +115,6 @@ describe('OxyServices.onTokensChanged', () => {
|
|
|
115
115
|
deviceId: 'dev_1',
|
|
116
116
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
117
117
|
accessToken: 'access_verify',
|
|
118
|
-
refreshToken: 'refresh_verify',
|
|
119
118
|
user: { id: 'user_1', username: 'tester' },
|
|
120
119
|
} as never;
|
|
121
120
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Device-first password sign-in (`passwordSignIn` + `completeTwoFactorSignIn`).
|
|
3
3
|
* Stubs `makeRequest` and asserts the login-result contract handling: the 2FA
|
|
4
|
-
* arm passes through un-planted, the session arm plants its access token
|
|
5
|
-
*
|
|
6
|
-
* a
|
|
4
|
+
* arm passes through un-planted, the session arm plants its access token and
|
|
5
|
+
* carries the zero-cookie `deviceId` + `deviceSecret` restore credential,
|
|
6
|
+
* `deviceName` / `deviceFingerprint` are threaded into the request, and a 2FA
|
|
7
|
+
* arm returned from verify-login is a protocol error.
|
|
7
8
|
*/
|
|
8
9
|
import type { LoginResult } from '@oxyhq/contracts';
|
|
9
10
|
import { OxyServices } from '../../OxyServices';
|
|
@@ -13,7 +14,7 @@ const SESSION_ARM: LoginResult = {
|
|
|
13
14
|
deviceId: 'dev-1',
|
|
14
15
|
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
15
16
|
accessToken: 'access-1',
|
|
16
|
-
|
|
17
|
+
deviceSecret: 'ds-secret-1',
|
|
17
18
|
user: { id: 'user-1', username: 'u' },
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -39,19 +40,42 @@ describe('passwordSignIn', () => {
|
|
|
39
40
|
expect(setTokens).not.toHaveBeenCalled();
|
|
40
41
|
});
|
|
41
42
|
|
|
42
|
-
it('plants the access token on the session arm and threads
|
|
43
|
+
it('plants the access token on the session arm, exposes the mint credential, and threads deviceName + deviceFingerprint', async () => {
|
|
43
44
|
makeRequest.mockResolvedValueOnce(SESSION_ARM);
|
|
44
|
-
const result = await oxy.passwordSignIn('alice', 'pw', {
|
|
45
|
+
const result = await oxy.passwordSignIn('alice', 'pw', { deviceName: 'Phone', deviceFingerprint: 'fp-1' });
|
|
45
46
|
expect(result).toEqual(SESSION_ARM);
|
|
47
|
+
// The zero-cookie restore credential is on the session arm the caller persists.
|
|
48
|
+
expect('twoFactorRequired' in result).toBe(false);
|
|
49
|
+
if (!('twoFactorRequired' in result)) {
|
|
50
|
+
expect(result.deviceId).toBe('dev-1');
|
|
51
|
+
expect(result.deviceSecret).toBe('ds-secret-1');
|
|
52
|
+
}
|
|
46
53
|
expect(setTokens).toHaveBeenCalledWith('access-1');
|
|
47
54
|
expect(makeRequest).toHaveBeenCalledWith(
|
|
48
55
|
'POST',
|
|
49
56
|
'/auth/login',
|
|
50
|
-
{ identifier: 'alice', password: 'pw', deviceName: 'Phone', deviceFingerprint:
|
|
57
|
+
{ identifier: 'alice', password: 'pw', deviceName: 'Phone', deviceFingerprint: 'fp-1' },
|
|
51
58
|
{ cache: false },
|
|
52
59
|
);
|
|
53
60
|
});
|
|
54
61
|
|
|
62
|
+
it('preserves the securityAlert on the session arm (contract parse must not strip it)', async () => {
|
|
63
|
+
const withAlert: LoginResult = {
|
|
64
|
+
...SESSION_ARM,
|
|
65
|
+
securityAlert: {
|
|
66
|
+
message: 'Unusual activity detected on your account',
|
|
67
|
+
anomalies: [{ type: 'new_device', reason: 'first seen', details: 'Chrome / macOS' }],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
makeRequest.mockResolvedValueOnce(withAlert);
|
|
71
|
+
const result = await oxy.passwordSignIn('alice', 'pw');
|
|
72
|
+
expect('twoFactorRequired' in result).toBe(false);
|
|
73
|
+
if (!('twoFactorRequired' in result)) {
|
|
74
|
+
expect(result.securityAlert?.message).toBe('Unusual activity detected on your account');
|
|
75
|
+
expect(result.securityAlert?.anomalies[0]?.type).toBe('new_device');
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
55
79
|
it('throws on an unexpected response shape', async () => {
|
|
56
80
|
makeRequest.mockResolvedValueOnce({ nope: true });
|
|
57
81
|
await expect(oxy.passwordSignIn('alice', 'pw')).rejects.toThrow();
|
|
@@ -73,13 +97,13 @@ describe('completeTwoFactorSignIn', () => {
|
|
|
73
97
|
|
|
74
98
|
it('verifies the login token, plants the session, and POSTs to /security/2fa/verify-login', async () => {
|
|
75
99
|
makeRequest.mockResolvedValueOnce(SESSION_ARM);
|
|
76
|
-
const result = await oxy.completeTwoFactorSignIn({ loginToken: 'lt', token: '123456',
|
|
100
|
+
const result = await oxy.completeTwoFactorSignIn({ loginToken: 'lt', token: '123456', deviceName: 'Phone' });
|
|
77
101
|
expect(result).toEqual(SESSION_ARM);
|
|
78
102
|
expect(setTokens).toHaveBeenCalledWith('access-1');
|
|
79
103
|
expect(makeRequest).toHaveBeenCalledWith(
|
|
80
104
|
'POST',
|
|
81
105
|
'/security/2fa/verify-login',
|
|
82
|
-
{ loginToken: 'lt', token: '123456', backupCode: undefined,
|
|
106
|
+
{ loginToken: 'lt', token: '123456', backupCode: undefined, deviceName: 'Phone' },
|
|
83
107
|
{ cache: false },
|
|
84
108
|
);
|
|
85
109
|
});
|
package/src/mixins/index.ts
CHANGED
|
@@ -129,9 +129,8 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
129
129
|
// so apps stop scraping link metadata locally.
|
|
130
130
|
OxyServicesLinksMixin,
|
|
131
131
|
|
|
132
|
-
// Device-first
|
|
133
|
-
// /
|
|
134
|
-
// refresh, native device-token, bootstrap-url builder).
|
|
132
|
+
// Device-first token mint: the client half of the zero-cookie transport
|
|
133
|
+
// (`mintFromDeviceSecret` → `POST /session/device/token`).
|
|
135
134
|
OxyServicesDeviceBootMixin,
|
|
136
135
|
|
|
137
136
|
// Utility (last, can use all above)
|