@oxyhq/core 8.0.0 → 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/coldBootV2.js +66 -281
- 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 +24 -92
- 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 +14 -88
- package/dist/cjs/session/createSessionClient.js +2 -9
- package/dist/cjs/session/refresh.js +46 -62
- package/dist/cjs/utils/registrableApex.js +2 -6
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/coldBootV2.js +67 -279
- 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 +25 -93
- 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 +13 -87
- package/dist/esm/session/createSessionClient.js +2 -9
- package/dist/esm/session/refresh.js +46 -62
- 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 +22 -46
- 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 +38 -43
- 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 +237 -236
- package/src/boot/coldBootV2.ts +92 -333
- 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 +30 -115
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +36 -80
- 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 +47 -33
- package/src/session/__tests__/refresh.test.ts +72 -44
- package/src/session/accountDialogController.ts +4 -18
- package/src/session/authStateStore.ts +43 -111
- package/src/session/createSessionClient.ts +2 -9
- package/src/session/refresh.ts +60 -83
- package/src/utils/registrableApex.ts +2 -6
- package/dist/cjs/boot/deviceBootReturn.js +0 -152
- package/dist/esm/boot/deviceBootReturn.js +0 -146
- package/dist/types/boot/deviceBootReturn.d.ts +0 -83
- package/src/boot/__tests__/deviceBootReturn.test.ts +0 -158
- package/src/boot/deviceBootReturn.ts +0 -195
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +0 -24
- package/src/session/__tests__/SessionClient.signedOut.test.ts +0 -224
|
@@ -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,146 +1,61 @@
|
|
|
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
|
-
|
|
20
|
-
tokenRefreshResponseSchema,
|
|
21
|
-
deviceTokenIssueResponseSchema,
|
|
22
|
-
webSessionResultSchema,
|
|
17
|
+
deviceTokenMintResponseSchema,
|
|
23
18
|
safeParseContract,
|
|
24
|
-
type
|
|
25
|
-
type TokenRefreshResponse,
|
|
26
|
-
type WebSessionResult,
|
|
19
|
+
type DeviceTokenMintResponse,
|
|
27
20
|
} from '@oxyhq/contracts';
|
|
28
21
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
29
22
|
|
|
30
23
|
export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
31
24
|
return class extends Base {
|
|
32
25
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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`.
|
|
35
31
|
*
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
'POST',
|
|
42
|
-
'/auth/device/exchange',
|
|
43
|
-
{ code },
|
|
44
|
-
{ cache: false },
|
|
45
|
-
);
|
|
46
|
-
const parsed = safeParseContract(authTokenBundleSchema, res);
|
|
47
|
-
if (!parsed) {
|
|
48
|
-
throw new Error('device/exchange returned an unexpected response shape');
|
|
49
|
-
}
|
|
50
|
-
return parsed;
|
|
51
|
-
} catch (error) {
|
|
52
|
-
throw this.handleError(error);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Same-site fast path (`*.oxy.so` apps). Reads the first-party `oxy_device`
|
|
58
|
-
* cookie via a credentialed same-origin fetch and either resolves a full
|
|
59
|
-
* session bundle or reports the device is known-but-signed-out. Never
|
|
60
|
-
* redirects.
|
|
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.
|
|
61
37
|
*
|
|
62
|
-
* @throws if the response
|
|
38
|
+
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
63
39
|
*/
|
|
64
|
-
async
|
|
40
|
+
async mintFromDeviceSecret(
|
|
41
|
+
deviceId: string,
|
|
42
|
+
deviceSecret: string,
|
|
43
|
+
): Promise<DeviceTokenMintResponse> {
|
|
65
44
|
try {
|
|
66
45
|
const res = await this.makeRequest<unknown>(
|
|
67
46
|
'POST',
|
|
68
|
-
'/
|
|
69
|
-
|
|
70
|
-
{ cache: false },
|
|
71
|
-
);
|
|
72
|
-
const parsed = safeParseContract(webSessionResultSchema, res);
|
|
73
|
-
if (!parsed) {
|
|
74
|
-
throw new Error('device/web-session returned an unexpected response shape');
|
|
75
|
-
}
|
|
76
|
-
return parsed;
|
|
77
|
-
} catch (error) {
|
|
78
|
-
throw this.handleError(error);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Rotate the persisted refresh-token family (web + native, one call). The
|
|
84
|
-
* caller (refresh handler / cold boot) plants + persists the rotated pair.
|
|
85
|
-
*
|
|
86
|
-
* @throws if the response does not match {@link tokenRefreshResponseSchema}.
|
|
87
|
-
*/
|
|
88
|
-
async refreshWithToken(refreshToken: string): Promise<TokenRefreshResponse> {
|
|
89
|
-
try {
|
|
90
|
-
const res = await this.makeRequest<unknown>(
|
|
91
|
-
'POST',
|
|
92
|
-
'/auth/refresh-token',
|
|
93
|
-
{ refreshToken },
|
|
94
|
-
// `skipAuth`: refresh-token is body-authenticated and is called from
|
|
95
|
-
// inside the refresh handler — sending the near-expired bearer through
|
|
96
|
-
// the preflight would deadlock. See RequestOptions.skipAuth.
|
|
47
|
+
'/session/device/token',
|
|
48
|
+
{ deviceId, deviceSecret },
|
|
97
49
|
{ cache: false, skipAuth: true },
|
|
98
50
|
);
|
|
99
|
-
const parsed = safeParseContract(
|
|
51
|
+
const parsed = safeParseContract(deviceTokenMintResponseSchema, res);
|
|
100
52
|
if (!parsed) {
|
|
101
|
-
throw new Error('
|
|
53
|
+
throw new Error('session/device/token returned an unexpected response shape');
|
|
102
54
|
}
|
|
103
55
|
return parsed;
|
|
104
56
|
} catch (error) {
|
|
105
57
|
throw this.handleError(error);
|
|
106
58
|
}
|
|
107
59
|
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Issue (or rotate) the native channel's opaque device token. Bearer-gated —
|
|
111
|
-
* the server derives the deviceId from the JWT. Native apps mirror the
|
|
112
|
-
* returned token into the shared keychain via
|
|
113
|
-
* `KeyManager.setSharedDeviceToken`.
|
|
114
|
-
*
|
|
115
|
-
* @throws if the response does not match {@link deviceTokenIssueResponseSchema}.
|
|
116
|
-
*/
|
|
117
|
-
async issueNativeDeviceToken(): Promise<string> {
|
|
118
|
-
try {
|
|
119
|
-
const res = await this.makeRequest<unknown>(
|
|
120
|
-
'POST',
|
|
121
|
-
'/auth/device/token',
|
|
122
|
-
undefined,
|
|
123
|
-
{ cache: false },
|
|
124
|
-
);
|
|
125
|
-
const parsed = safeParseContract(deviceTokenIssueResponseSchema, res);
|
|
126
|
-
if (!parsed) {
|
|
127
|
-
throw new Error('auth/device/token returned an unexpected response shape');
|
|
128
|
-
}
|
|
129
|
-
return parsed.deviceToken;
|
|
130
|
-
} catch (error) {
|
|
131
|
-
throw this.handleError(error);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Build the top-level `GET /auth/device/bootstrap` URL for the cross-apex
|
|
137
|
-
* hop. The server validates `return_to` against the trusted-origin lane and
|
|
138
|
-
* echoes `state` back in the return fragment for CSRF verification.
|
|
139
|
-
*/
|
|
140
|
-
buildBootstrapUrl(returnTo: string, state: string): string {
|
|
141
|
-
const base = this.getBaseURL().replace(/\/+$/, '');
|
|
142
|
-
const params = new URLSearchParams({ return_to: returnTo, state });
|
|
143
|
-
return `${base}/auth/device/bootstrap?${params.toString()}`;
|
|
144
|
-
}
|
|
145
60
|
};
|
|
146
61
|
}
|
|
@@ -1,26 +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 {
|
|
6
|
+
import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
|
|
7
7
|
import { OxyServices } from '../../OxyServices';
|
|
8
8
|
|
|
9
|
-
const BUNDLE: AuthTokenBundle = {
|
|
10
|
-
sessionId: 'sess-1',
|
|
11
|
-
accessToken: 'access-1',
|
|
12
|
-
refreshToken: 'refresh-abcdefghijkl',
|
|
13
|
-
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
14
|
-
user: { id: 'user-1', name: {} } as AuthTokenBundle['user'],
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const ROTATED: TokenRefreshResponse = {
|
|
18
|
-
accessToken: 'access-2',
|
|
19
|
-
refreshToken: 'refresh-mnopqrstuvwx',
|
|
20
|
-
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
21
|
-
sessionId: 'sess-1',
|
|
22
|
-
};
|
|
23
|
-
|
|
24
9
|
describe('OxyServices.deviceBoot', () => {
|
|
25
10
|
let oxy: OxyServices;
|
|
26
11
|
let makeRequest: jest.SpyInstance;
|
|
@@ -32,76 +17,47 @@ describe('OxyServices.deviceBoot', () => {
|
|
|
32
17
|
|
|
33
18
|
afterEach(() => jest.restoreAllMocks());
|
|
34
19
|
|
|
35
|
-
describe('
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
const result = await oxy.requestWebSession();
|
|
54
|
-
expect(result).toEqual(sessionArm);
|
|
55
|
-
expect(makeRequest).toHaveBeenCalledWith('POST', '/auth/device/web-session', undefined, { cache: false });
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('returns the no-session arm', async () => {
|
|
59
|
-
const noSession: WebSessionResult = { reason: 'no_session', deviceToken: 'device-abcdefghij' };
|
|
60
|
-
makeRequest.mockResolvedValueOnce(noSession);
|
|
61
|
-
const result = await oxy.requestWebSession();
|
|
62
|
-
expect(result.reason).toBe('no_session');
|
|
63
|
-
expect(result).toEqual(noSession);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it('throws on the OLD bare-bundle shape (regression: server now wraps in a reason envelope)', async () => {
|
|
67
|
-
makeRequest.mockResolvedValueOnce(BUNDLE);
|
|
68
|
-
await expect(oxy.requestWebSession()).rejects.toThrow();
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('throws when the response matches neither arm', async () => {
|
|
72
|
-
makeRequest.mockResolvedValueOnce({ reason: 'weird' });
|
|
73
|
-
await expect(oxy.requestWebSession()).rejects.toThrow();
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
describe('refreshWithToken', () => {
|
|
78
|
-
it('POSTs with skipAuth and returns the rotated pair', async () => {
|
|
79
|
-
makeRequest.mockResolvedValueOnce(ROTATED);
|
|
80
|
-
const result = await oxy.refreshWithToken('refresh-abcdefghijkl');
|
|
81
|
-
expect(result).toEqual(ROTATED);
|
|
20
|
+
describe('mintFromDeviceSecret', () => {
|
|
21
|
+
const MINT: DeviceTokenMintResponse = {
|
|
22
|
+
accessToken: 'access-minted',
|
|
23
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
24
|
+
nextDeviceSecret: 'ds-next-secret',
|
|
25
|
+
state: {
|
|
26
|
+
deviceId: 'dev-1',
|
|
27
|
+
accounts: [{ accountId: 'user-1', sessionId: 'sess-1', authuser: 0 }],
|
|
28
|
+
activeAccountId: 'user-1',
|
|
29
|
+
revision: 3,
|
|
30
|
+
updatedAt: 1_700_000_000_000,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
it('POSTs deviceId + deviceSecret with skipAuth (no bearer, no cache) and returns the validated mint', async () => {
|
|
35
|
+
makeRequest.mockResolvedValueOnce(MINT);
|
|
36
|
+
const result = await oxy.mintFromDeviceSecret('dev-1', 'ds-current-secret');
|
|
37
|
+
expect(result).toEqual(MINT);
|
|
82
38
|
expect(makeRequest).toHaveBeenCalledWith(
|
|
83
39
|
'POST',
|
|
84
|
-
'/
|
|
85
|
-
{
|
|
40
|
+
'/session/device/token',
|
|
41
|
+
{ deviceId: 'dev-1', deviceSecret: 'ds-current-secret' },
|
|
86
42
|
{ cache: false, skipAuth: true },
|
|
87
43
|
);
|
|
88
44
|
});
|
|
89
|
-
});
|
|
90
45
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
46
|
+
it('throws on an unexpected response shape (missing state / nextDeviceSecret)', async () => {
|
|
47
|
+
makeRequest.mockResolvedValueOnce({ accessToken: 'a', expiresAt: 'b' });
|
|
48
|
+
await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('propagates a rejected request (e.g. 401 invalid_device_secret)', async () => {
|
|
52
|
+
const err = Object.assign(new Error('invalid_device_secret'), { status: 401 });
|
|
53
|
+
makeRequest.mockRejectedValueOnce(err);
|
|
54
|
+
await expect(oxy.mintFromDeviceSecret('dev-1', 'ds')).rejects.toThrow('invalid_device_secret');
|
|
96
55
|
});
|
|
97
|
-
});
|
|
98
56
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
expect(
|
|
103
|
-
expect(url).toContain('return_to=https%3A%2F%2Faccounts.oxy.so%2Fhome');
|
|
104
|
-
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');
|
|
105
61
|
});
|
|
106
62
|
});
|
|
107
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)
|