@oxyhq/core 15.0.0 → 15.0.1
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/mixins/OxyServices.deviceBoot.js +18 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/mixins/OxyServices.deviceBoot.js +18 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +18 -0
- package/package.json +1 -1
- package/src/mixins/OxyServices.deviceBoot.ts +18 -0
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +135 -0
|
@@ -104,6 +104,24 @@ export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesB
|
|
|
104
104
|
* method would be dead code plus a second implementation of the failure
|
|
105
105
|
* rules. The asymmetry is the design.
|
|
106
106
|
*
|
|
107
|
+
* **Call this from NATIVE only.** There is no background worker on web to
|
|
108
|
+
* consume the credential, and handing a browser origin a long-lived
|
|
109
|
+
* non-rotating secret to persist is strictly weaker than the rotating device
|
|
110
|
+
* secret it already holds. The 404 degrade below is also native-shaped: a
|
|
111
|
+
* browser attaches `Origin`, which a server predating this route answers
|
|
112
|
+
* `403 BAD_ORIGIN` from its router-wide same-site guard rather than 404, so
|
|
113
|
+
* the quiet degrade would not fire there. A native client sends no `Origin`
|
|
114
|
+
* and gets the 404. Gate the caller by platform; do not widen the degrade to
|
|
115
|
+
* 403, which would also swallow a genuine origin misconfiguration.
|
|
116
|
+
*
|
|
117
|
+
* That paragraph is LOAD-BEARING, not belt-and-braces: the route sits above
|
|
118
|
+
* oxy-api's router-wide origin guard (deliberately, so a native client with
|
|
119
|
+
* no `Origin` is not rejected), so as of this writing NOTHING server-side
|
|
120
|
+
* refuses a browser caller that presents a valid bearer. Until a server-side
|
|
121
|
+
* check lands, caller discipline is the only control — which is also why a
|
|
122
|
+
* doc note cannot be the whole answer to browser XSS minting a long-lived
|
|
123
|
+
* credential with the victim's bearer.
|
|
124
|
+
*
|
|
107
125
|
* @returns the provisioned credential, or `null` when the endpoint is absent
|
|
108
126
|
* (404). The API deploy leads the SDK release, so a client on a newer SDK
|
|
109
127
|
* than the server degrades to "no background session" quietly instead of
|
package/package.json
CHANGED
|
@@ -157,6 +157,24 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
157
157
|
* method would be dead code plus a second implementation of the failure
|
|
158
158
|
* rules. The asymmetry is the design.
|
|
159
159
|
*
|
|
160
|
+
* **Call this from NATIVE only.** There is no background worker on web to
|
|
161
|
+
* consume the credential, and handing a browser origin a long-lived
|
|
162
|
+
* non-rotating secret to persist is strictly weaker than the rotating device
|
|
163
|
+
* secret it already holds. The 404 degrade below is also native-shaped: a
|
|
164
|
+
* browser attaches `Origin`, which a server predating this route answers
|
|
165
|
+
* `403 BAD_ORIGIN` from its router-wide same-site guard rather than 404, so
|
|
166
|
+
* the quiet degrade would not fire there. A native client sends no `Origin`
|
|
167
|
+
* and gets the 404. Gate the caller by platform; do not widen the degrade to
|
|
168
|
+
* 403, which would also swallow a genuine origin misconfiguration.
|
|
169
|
+
*
|
|
170
|
+
* That paragraph is LOAD-BEARING, not belt-and-braces: the route sits above
|
|
171
|
+
* oxy-api's router-wide origin guard (deliberately, so a native client with
|
|
172
|
+
* no `Origin` is not rejected), so as of this writing NOTHING server-side
|
|
173
|
+
* refuses a browser caller that presents a valid bearer. Until a server-side
|
|
174
|
+
* check lands, caller discipline is the only control — which is also why a
|
|
175
|
+
* doc note cannot be the whole answer to browser XSS minting a long-lived
|
|
176
|
+
* credential with the victim's bearer.
|
|
177
|
+
*
|
|
160
178
|
* @returns the provisioned credential, or `null` when the endpoint is absent
|
|
161
179
|
* (404). The API deploy leads the SDK release, so a client on a newer SDK
|
|
162
180
|
* than the server degrades to "no background session" quietly instead of
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { DeviceBackgroundCredentialResponse } from '@oxyhq/contracts';
|
|
2
|
+
import { OxyServices } from '../../OxyServices';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Real-stack integration test for `provisionBackgroundCredential`: a genuine
|
|
6
|
+
* `HttpService` (via `OxyServices`) with `global.fetch` stubbed to return the
|
|
7
|
+
* EXACT wire bodies oxy-api sends, rather than a `makeRequest` spy.
|
|
8
|
+
*
|
|
9
|
+
* The unit suite (`OxyServices.deviceBoot.test.ts`) stubs `makeRequest`, so by
|
|
10
|
+
* construction it cannot see either of the two things this file pins:
|
|
11
|
+
*
|
|
12
|
+
* 1. **The `{ data }` envelope.** The route answers
|
|
13
|
+
* `{ data: { deviceId, secret, accountId, expiresAt } }`, and
|
|
14
|
+
* `HttpService.unwrapResponse` strips that outer envelope — so the mixin
|
|
15
|
+
* must validate the FLAT credential and must not read `.data` a second
|
|
16
|
+
* time. A spy returning the flat shape asserts that assumption instead of
|
|
17
|
+
* testing it; the same blind spot produced a P0 in `SessionClient` (see
|
|
18
|
+
* `SessionClient.httpIntegration.test.ts`).
|
|
19
|
+
* 2. **The 404 degrade against the REAL error object.** The `404 → null` path
|
|
20
|
+
* is what keeps a client on a newer SDK than the server from breaking, and
|
|
21
|
+
* it keys on the status surviving whatever `HttpService` throws. A
|
|
22
|
+
* hand-built `Object.assign(new Error(), { status: 404 })` proves only that
|
|
23
|
+
* the branch reads the shape the test itself invented.
|
|
24
|
+
*
|
|
25
|
+
* The stub is URL-aware because a state-changing request through the real stack
|
|
26
|
+
* fetches `GET /csrf-token` FIRST. A blanket stub answers that call too, which
|
|
27
|
+
* both shifts the request under test out of `calls[0]` and (on a non-200 stub)
|
|
28
|
+
* makes the CSRF fetch burn its own retries — so a naive call-count assertion
|
|
29
|
+
* measures CSRF attempts rather than the route.
|
|
30
|
+
*/
|
|
31
|
+
const ROUTE = '/session/device/background-credential';
|
|
32
|
+
|
|
33
|
+
const CREDENTIAL: DeviceBackgroundCredentialResponse = {
|
|
34
|
+
deviceId: 'device-real',
|
|
35
|
+
secret: 'bg-secret-from-the-wire',
|
|
36
|
+
accountId: 'acct-1',
|
|
37
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The route's success body: the credential under this API's `data` envelope. */
|
|
41
|
+
const ROUTE_BODY = { data: CREDENTIAL };
|
|
42
|
+
|
|
43
|
+
/** oxy-api's 404 body for an unmatched path (server.ts's terminal handler). */
|
|
44
|
+
const NOT_FOUND_BODY = { error: 'NOT_FOUND', message: 'Resource not found' };
|
|
45
|
+
|
|
46
|
+
const jsonResponse = (body: unknown, status: number) =>
|
|
47
|
+
new Response(JSON.stringify(body), {
|
|
48
|
+
status,
|
|
49
|
+
headers: { 'content-type': 'application/json' },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A syntactically real, far-future access token. It must be a decodable JWT:
|
|
54
|
+
* `HttpService.getAuthHeader` runs `jwtDecode` and sends NO bearer at all when
|
|
55
|
+
* that throws, so an opaque placeholder would silently turn this into an
|
|
56
|
+
* anonymous request and make the bearer assertion below untestable.
|
|
57
|
+
*/
|
|
58
|
+
const ACCESS_TOKEN = (() => {
|
|
59
|
+
const segment = (payload: object) => Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
60
|
+
return [
|
|
61
|
+
segment({ alg: 'none', typ: 'JWT' }),
|
|
62
|
+
segment({ sub: 'acct-1', exp: 4_102_444_800 }), // 2100-01-01
|
|
63
|
+
'signature-not-verified-client-side',
|
|
64
|
+
].join('.');
|
|
65
|
+
})();
|
|
66
|
+
|
|
67
|
+
describe('provisionBackgroundCredential over a real HttpService', () => {
|
|
68
|
+
const originalFetch = global.fetch;
|
|
69
|
+
|
|
70
|
+
/** Answers the CSRF preflight properly; answers the route under test with `body`/`status`. */
|
|
71
|
+
const stubFetch = (body: unknown, status: number) => {
|
|
72
|
+
const fetchMock = jest.fn(async (input: unknown) => {
|
|
73
|
+
if (String(input).includes('/csrf-token')) {
|
|
74
|
+
return jsonResponse({ csrfToken: 'csrf-test-token' }, 200);
|
|
75
|
+
}
|
|
76
|
+
return jsonResponse(body, status);
|
|
77
|
+
});
|
|
78
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
79
|
+
return fetchMock;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Every stubbed call whose URL is the route under test (i.e. not the CSRF preflight). */
|
|
83
|
+
const routeCalls = (fetchMock: jest.Mock) =>
|
|
84
|
+
fetchMock.mock.calls.filter(([input]) => String(input).includes(ROUTE));
|
|
85
|
+
|
|
86
|
+
const client = () => {
|
|
87
|
+
const oxy = new OxyServices({ baseURL: 'http://api.test.invalid' });
|
|
88
|
+
// Bearer required: the server derives both the deviceId and the account
|
|
89
|
+
// from it, and this call (unlike the device-secret mint) does not skipAuth.
|
|
90
|
+
oxy.setTokens(ACCESS_TOKEN);
|
|
91
|
+
return oxy;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
global.fetch = originalFetch;
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('unwraps the { data } envelope and returns the flat credential', async () => {
|
|
99
|
+
stubFetch(ROUTE_BODY, 200);
|
|
100
|
+
|
|
101
|
+
const result = await client().provisionBackgroundCredential();
|
|
102
|
+
|
|
103
|
+
// Would be `{ data: {...} }` if the envelope were not unwrapped, and would
|
|
104
|
+
// throw (contract validation failure) if `.data` were read twice.
|
|
105
|
+
expect(result).toEqual(CREDENTIAL);
|
|
106
|
+
expect(result?.secret).toBe('bg-secret-from-the-wire');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('sends a POST to the route with NO body and a bearer', async () => {
|
|
110
|
+
const fetchMock = stubFetch(ROUTE_BODY, 200);
|
|
111
|
+
|
|
112
|
+
await client().provisionBackgroundCredential();
|
|
113
|
+
|
|
114
|
+
const calls = routeCalls(fetchMock);
|
|
115
|
+
expect(calls).toHaveLength(1);
|
|
116
|
+
const [, init] = calls[0] as unknown as [string, RequestInit];
|
|
117
|
+
expect(init.method).toBe('POST');
|
|
118
|
+
// No body at all — not `'undefined'`, not `'{}'`. The server derives the
|
|
119
|
+
// deviceId and the account from the bearer; anything sent here would be
|
|
120
|
+
// ignored at best and mass-assignment surface at worst.
|
|
121
|
+
expect(init.body ?? null).toBeNull();
|
|
122
|
+
expect(new Headers(init.headers).get('authorization')).toBe(`Bearer ${ACCESS_TOKEN}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns null on the real 404 response, without retrying the route', async () => {
|
|
126
|
+
const fetchMock = stubFetch(NOT_FOUND_BODY, 404);
|
|
127
|
+
|
|
128
|
+
await expect(client().provisionBackgroundCredential()).resolves.toBeNull();
|
|
129
|
+
|
|
130
|
+
// 4xx is not retried (`retryAsync`'s default shouldRetry), so an absent
|
|
131
|
+
// endpoint costs exactly one request to the route — the degrade must not
|
|
132
|
+
// burn a backoff loop on every provision attempt.
|
|
133
|
+
expect(routeCalls(fetchMock)).toHaveLength(1);
|
|
134
|
+
});
|
|
135
|
+
});
|