@oxy.so/core 1.5.1 → 1.6.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/HttpService.js +72 -9
- package/dist/cjs/crypto/identityMove.js +53 -34
- package/dist/cjs/crypto/identityProof.js +28 -0
- package/dist/cjs/crypto/webIdentityCarrier.js +145 -82
- package/dist/cjs/i18n/locales/ar-SA.json +1 -0
- package/dist/cjs/i18n/locales/ca-ES.json +1 -0
- package/dist/cjs/i18n/locales/de-DE.json +1 -0
- package/dist/cjs/i18n/locales/en-US.json +1 -0
- package/dist/cjs/i18n/locales/es-ES.json +1 -0
- package/dist/cjs/i18n/locales/fr-FR.json +1 -0
- package/dist/cjs/i18n/locales/it-IT.json +1 -0
- package/dist/cjs/i18n/locales/ja-JP.json +1 -0
- package/dist/cjs/i18n/locales/ko-KR.json +1 -0
- package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
- package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
- package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
- package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
- package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
- package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
- package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
- package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
- package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
- package/dist/cjs/i18n/locales/pt-PT.json +1 -0
- package/dist/cjs/i18n/locales/zh-CN.json +1 -0
- package/dist/cjs/index.js +22 -14
- package/dist/cjs/inference/OxyInferenceClient.js +62 -5
- package/dist/cjs/mixins/OxyServices.auth.js +129 -0
- package/dist/cjs/mixins/OxyServices.identity.js +11 -44
- package/dist/cjs/mixins/index.js +0 -2
- package/dist/cjs/server/index.js +10 -1
- package/dist/cjs/server/securityHeaders.js +9 -2
- package/dist/cjs/server/traffic.js +29 -2
- package/dist/cjs/server/workloadIdentity.js +170 -0
- package/dist/cjs/session/refresh.js +12 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +72 -9
- package/dist/esm/crypto/identityMove.js +51 -33
- package/dist/esm/crypto/identityProof.js +24 -0
- package/dist/esm/crypto/webIdentityCarrier.js +137 -77
- package/dist/esm/i18n/locales/ar-SA.json +1 -0
- package/dist/esm/i18n/locales/ca-ES.json +1 -0
- package/dist/esm/i18n/locales/de-DE.json +1 -0
- package/dist/esm/i18n/locales/en-US.json +1 -0
- package/dist/esm/i18n/locales/es-ES.json +1 -0
- package/dist/esm/i18n/locales/fr-FR.json +1 -0
- package/dist/esm/i18n/locales/it-IT.json +1 -0
- package/dist/esm/i18n/locales/ja-JP.json +1 -0
- package/dist/esm/i18n/locales/ko-KR.json +1 -0
- package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
- package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
- package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
- package/dist/esm/i18n/locales/locales/en-US.json +1 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
- package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
- package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
- package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
- package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
- package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
- package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
- package/dist/esm/i18n/locales/pt-PT.json +1 -0
- package/dist/esm/i18n/locales/zh-CN.json +1 -0
- package/dist/esm/index.js +5 -4
- package/dist/esm/inference/OxyInferenceClient.js +63 -6
- package/dist/esm/mixins/OxyServices.auth.js +96 -0
- package/dist/esm/mixins/OxyServices.identity.js +11 -44
- package/dist/esm/mixins/index.js +0 -2
- package/dist/esm/server/index.js +7 -0
- package/dist/esm/server/securityHeaders.js +9 -2
- package/dist/esm/server/traffic.js +29 -2
- package/dist/esm/server/workloadIdentity.js +166 -0
- package/dist/esm/session/refresh.js +12 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +24 -0
- package/dist/types/crypto/identityMove.d.ts +48 -32
- package/dist/types/crypto/identityProof.d.ts +16 -0
- package/dist/types/crypto/webIdentityCarrier.d.ts +74 -47
- package/dist/types/index.d.ts +7 -6
- package/dist/types/inference/OxyInferenceClient.d.ts +21 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +32 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +16 -30
- package/dist/types/mixins/index.d.ts +1 -2
- package/dist/types/server/index.d.ts +8 -0
- package/dist/types/server/securityHeaders.d.ts +8 -0
- package/dist/types/server/workloadIdentity.d.ts +32 -0
- package/package.json +2 -2
- package/src/HttpService.ts +74 -9
- package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
- package/src/__tests__/serviceTokenWorkloadFallback.test.ts +85 -0
- package/src/crypto/__tests__/identityMove.test.ts +93 -51
- package/src/crypto/__tests__/identityProof.test.ts +81 -0
- package/src/crypto/__tests__/webIdentityCarrier.test.ts +21 -63
- package/src/crypto/__tests__/webIdentityRecoveryMaterial.test.ts +117 -0
- package/src/crypto/identityMove.ts +77 -43
- package/src/crypto/identityProof.ts +36 -0
- package/src/crypto/webIdentityCarrier.ts +185 -117
- package/src/i18n/locales/ar-SA.json +1 -0
- package/src/i18n/locales/ca-ES.json +1 -0
- package/src/i18n/locales/de-DE.json +1 -0
- package/src/i18n/locales/en-US.json +1 -0
- package/src/i18n/locales/es-ES.json +1 -0
- package/src/i18n/locales/fr-FR.json +1 -0
- package/src/i18n/locales/it-IT.json +1 -0
- package/src/i18n/locales/ja-JP.json +1 -0
- package/src/i18n/locales/ko-KR.json +1 -0
- package/src/i18n/locales/pt-PT.json +1 -0
- package/src/i18n/locales/zh-CN.json +1 -0
- package/src/index.ts +26 -15
- package/src/inference/OxyInferenceClient.ts +84 -7
- package/src/inference/__tests__/OxyInferenceClient.test.ts +48 -0
- package/src/mixins/OxyServices.auth.ts +100 -1
- package/src/mixins/OxyServices.identity.ts +17 -67
- package/src/mixins/__tests__/OxyServices.identity.test.ts +1 -76
- package/src/mixins/index.ts +0 -3
- package/src/server/__tests__/securityHeaders.test.ts +15 -0
- package/src/server/__tests__/traffic.test.ts +132 -1
- package/src/server/__tests__/workloadIdentity.test.ts +128 -0
- package/src/server/index.ts +9 -0
- package/src/server/securityHeaders.ts +19 -6
- package/src/server/traffic.ts +31 -1
- package/src/server/workloadIdentity.ts +207 -0
- package/src/session/__tests__/refresh.test.ts +53 -3
- package/src/session/refresh.ts +12 -0
- package/dist/cjs/mixins/OxyServices.deviceTransfer.js +0 -314
- package/dist/esm/mixins/OxyServices.deviceTransfer.js +0 -311
- package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +0 -149
- package/src/mixins/OxyServices.deviceTransfer.ts +0 -393
- package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +0 -271
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
import { createEcosystemTraffic } from '../traffic';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Every client the publisher builds, so a test can ask what it was handed.
|
|
5
|
+
*
|
|
6
|
+
* Named `mock*` because ts-jest hoists `jest.mock` above the imports and only
|
|
7
|
+
* lets a factory close over a binding with that prefix.
|
|
8
|
+
*/
|
|
9
|
+
const mockClients: Array<{ configureServiceAuth: jest.Mock; getServiceToken: jest.Mock }> = [];
|
|
10
|
+
|
|
11
|
+
jest.mock('../../OxyServices', () => ({
|
|
12
|
+
OxyServices: jest.fn(() => {
|
|
13
|
+
const client = {
|
|
14
|
+
configureServiceAuth: jest.fn(),
|
|
15
|
+
getServiceToken: jest.fn(async () => 'token-from-workload'),
|
|
16
|
+
};
|
|
17
|
+
mockClients.push(client);
|
|
18
|
+
return client;
|
|
19
|
+
}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
function lastClient() {
|
|
23
|
+
const client = mockClients[mockClients.length - 1];
|
|
24
|
+
if (!client) throw new Error('No OxyServices was built.');
|
|
25
|
+
return client;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Accepts every publish and remembers the headers, so nothing reaches a network. */
|
|
29
|
+
function recordingFetch(sent: RequestInit[]) {
|
|
30
|
+
return jest.fn(async (_input, init) => {
|
|
31
|
+
sent.push(init as RequestInit);
|
|
32
|
+
return new Response(null, { status: 204 });
|
|
33
|
+
}) as unknown as typeof fetch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function authorizations(sent: RequestInit[]): string[] {
|
|
37
|
+
return sent.map(init => (init.headers as Record<string, string>).Authorization);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A process with nothing configured and nothing to attest: a laptop.
|
|
42
|
+
*
|
|
43
|
+
* Emptied by assignment rather than `delete`, which Biome refuses on a property
|
|
44
|
+
* access — and `= undefined` would be worse than either, because Node stores the
|
|
45
|
+
* STRING "undefined" and every check here would read it as a configured value.
|
|
46
|
+
*/
|
|
47
|
+
function clearCredentialEnvironment() {
|
|
48
|
+
process.env.OXY_ACTIVITY_API_KEY = '';
|
|
49
|
+
process.env.OXY_ACTIVITY_API_SECRET = '';
|
|
50
|
+
process.env.OXY_SERVICE_API_KEY = '';
|
|
51
|
+
process.env.OXY_SERVICE_API_SECRET = '';
|
|
52
|
+
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '';
|
|
53
|
+
process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI = '';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
mockClients.length = 0;
|
|
58
|
+
});
|
|
4
59
|
|
|
5
60
|
it('publishes independently of viewers and waits for registration before removing a stopped instance', async () => {
|
|
6
61
|
const originalFetch = globalThis.fetch;
|
|
@@ -47,3 +102,79 @@ it('rejects a partial activity credential instead of mixing it with service cred
|
|
|
47
102
|
process.env = previous;
|
|
48
103
|
}
|
|
49
104
|
});
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Who may publish ecosystem activity, and who is still refused.
|
|
108
|
+
*
|
|
109
|
+
* ADR 0026 made "no api key, no secret" a legitimate shape for a first-party
|
|
110
|
+
* service: it attests its ECS task role instead. This constructor used to throw
|
|
111
|
+
* on exactly that shape, so the migration it asks for — delete two variables
|
|
112
|
+
* from a task definition — killed the process at boot, the circuit breaker
|
|
113
|
+
* rolled the deploy back, and the operator was told the service was stable.
|
|
114
|
+
*/
|
|
115
|
+
describe('ecosystem activity credentials', () => {
|
|
116
|
+
const previous = { ...process.env };
|
|
117
|
+
const originalFetch = globalThis.fetch;
|
|
118
|
+
|
|
119
|
+
afterEach(() => {
|
|
120
|
+
process.env = { ...previous };
|
|
121
|
+
globalThis.fetch = originalFetch;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('uses a service key pair when the process holds one', async () => {
|
|
125
|
+
clearCredentialEnvironment();
|
|
126
|
+
process.env.OXY_SERVICE_API_KEY = 'service-key';
|
|
127
|
+
process.env.OXY_SERVICE_API_SECRET = 'service-secret';
|
|
128
|
+
const sent: RequestInit[] = [];
|
|
129
|
+
globalThis.fetch = recordingFetch(sent);
|
|
130
|
+
|
|
131
|
+
const traffic = createEcosystemTraffic({ service: 'mention', region: 'us-west-2' });
|
|
132
|
+
await traffic.stop();
|
|
133
|
+
|
|
134
|
+
expect(lastClient().configureServiceAuth).toHaveBeenCalledWith('service-key', 'service-secret');
|
|
135
|
+
expect(lastClient().getServiceToken).toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('lets an explicit credential win over the environment', async () => {
|
|
139
|
+
clearCredentialEnvironment();
|
|
140
|
+
process.env.OXY_SERVICE_API_KEY = 'service-key';
|
|
141
|
+
process.env.OXY_SERVICE_API_SECRET = 'service-secret';
|
|
142
|
+
const sent: RequestInit[] = [];
|
|
143
|
+
globalThis.fetch = recordingFetch(sent);
|
|
144
|
+
|
|
145
|
+
const traffic = createEcosystemTraffic({ service: 'mention', region: 'us-west-2', credential: async () => 'explicit-token' });
|
|
146
|
+
await traffic.stop();
|
|
147
|
+
|
|
148
|
+
expect(authorizations(sent)).not.toContain('Bearer token-from-workload');
|
|
149
|
+
expect(authorizations(sent)).toContain('Bearer explicit-token');
|
|
150
|
+
expect(lastClient().getServiceToken).not.toHaveBeenCalled();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('refuses a process with no credential and nothing to attest', () => {
|
|
154
|
+
clearCredentialEnvironment();
|
|
155
|
+
|
|
156
|
+
// A laptop or a CI box: the container credentials endpoint is not going to
|
|
157
|
+
// appear later, so saying so at boot beats failing on the first heartbeat.
|
|
158
|
+
expect(() => createEcosystemTraffic({ service: 'homiio', region: 'us-west-2' })).toThrow(
|
|
159
|
+
/needs one of: an OXY_ACTIVITY_API_KEY and OXY_ACTIVITY_API_SECRET pair, an OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET pair, or a workload identity/
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('publishes with no key pair at all when the process can attest its workload identity', async () => {
|
|
164
|
+
clearCredentialEnvironment();
|
|
165
|
+
// The variable ECS sets on every task, and nothing else does. Stubbed, so
|
|
166
|
+
// `canAttestWorkloadIdentity()` answers yes without a credentials endpoint.
|
|
167
|
+
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/v2/credentials/abc';
|
|
168
|
+
const sent: RequestInit[] = [];
|
|
169
|
+
globalThis.fetch = recordingFetch(sent);
|
|
170
|
+
|
|
171
|
+
const traffic = createEcosystemTraffic({ service: 'mention', region: 'us-west-2' });
|
|
172
|
+
await traffic.stop();
|
|
173
|
+
|
|
174
|
+
// Unconfigured on purpose: that is what sends `getServiceToken()` down the
|
|
175
|
+
// workload path instead of handing it half a credential.
|
|
176
|
+
expect(lastClient().configureServiceAuth).not.toHaveBeenCalled();
|
|
177
|
+
expect(lastClient().getServiceToken).toHaveBeenCalled();
|
|
178
|
+
expect(authorizations(sent)).toContain('Bearer token-from-workload');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
import { canAttestWorkloadIdentity, requestWorkloadServiceToken } from '../workloadIdentity';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The client half of ADR 0026, and the two properties that decide whether it
|
|
7
|
+
* works at all against the verifier in `packages/api`:
|
|
8
|
+
*
|
|
9
|
+
* 1. **The nonce is inside the signature.** The verifier refuses an attestation
|
|
10
|
+
* whose `SignedHeaders` omits the nonce header, so a signer that merely sets
|
|
11
|
+
* the header produces attestations that are always rejected — a failure that
|
|
12
|
+
* would only show up in production, against the real verifier.
|
|
13
|
+
* 2. **The signed request is never sent to AWS.** Only Oxy replays it. A client
|
|
14
|
+
* that called STS itself would work in a test and leak an identity call per
|
|
15
|
+
* token in production.
|
|
16
|
+
*
|
|
17
|
+
* The signature's cryptographic correctness is AWS's to judge; what is asserted
|
|
18
|
+
* here is its SHAPE, which is what the two halves have to agree on.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const CREDENTIALS = {
|
|
22
|
+
AccessKeyId: 'ASIAEXAMPLE',
|
|
23
|
+
SecretAccessKey: 'secret-example',
|
|
24
|
+
Token: 'session-token-example',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function harness(overrides: { tokenStatus?: number } = {}) {
|
|
28
|
+
const calls: { url: string; init?: RequestInit }[] = [];
|
|
29
|
+
const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
30
|
+
const url = String(input);
|
|
31
|
+
calls.push({ url, init });
|
|
32
|
+
if (url.endsWith('/auth/service-token/workload/challenge')) {
|
|
33
|
+
return new Response(JSON.stringify({ data: { nonce: 'nonce-from-oxy', expiresIn: 60 } }), { status: 200 });
|
|
34
|
+
}
|
|
35
|
+
if (url.includes('169.254.170.2')) {
|
|
36
|
+
return new Response(JSON.stringify(CREDENTIALS), { status: 200 });
|
|
37
|
+
}
|
|
38
|
+
if (url.endsWith('/auth/service-token/workload')) {
|
|
39
|
+
const status = overrides.tokenStatus ?? 200;
|
|
40
|
+
if (status !== 200) return new Response('{}', { status });
|
|
41
|
+
return new Response(
|
|
42
|
+
JSON.stringify({ data: { token: 'service-token', expiresIn: 3600, appName: 'Mention' } }),
|
|
43
|
+
{ status: 200 },
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`unexpected request to ${url}`);
|
|
47
|
+
}) as unknown as typeof fetch;
|
|
48
|
+
return { calls, fetchImpl };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('workload identity client', () => {
|
|
52
|
+
const previous = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/v2/credentials/abc';
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterAll(() => {
|
|
59
|
+
// Cleared by assignment rather than `delete`: Biome refuses `delete` on a
|
|
60
|
+
// property access, and an empty value reads as "cannot attest" everywhere
|
|
61
|
+
// that matters here.
|
|
62
|
+
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = previous ?? '';
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('knows whether this process can attest at all', () => {
|
|
66
|
+
expect(canAttestWorkloadIdentity({ AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: '/v2/x' } as NodeJS.ProcessEnv)).toBe(true);
|
|
67
|
+
expect(canAttestWorkloadIdentity({} as NodeJS.ProcessEnv)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('answers the challenge with a signature that covers the nonce', async () => {
|
|
71
|
+
const { calls, fetchImpl } = harness();
|
|
72
|
+
|
|
73
|
+
const granted = await requestWorkloadServiceToken({ baseUrl: 'https://api.oxy.so/', fetch: fetchImpl });
|
|
74
|
+
|
|
75
|
+
expect(granted).toEqual({ token: 'service-token', expiresIn: 3600, appName: 'Mention' });
|
|
76
|
+
|
|
77
|
+
const exchange = calls.find((call) => call.url.endsWith('/auth/service-token/workload'));
|
|
78
|
+
const body = JSON.parse(String(exchange?.init?.body)) as {
|
|
79
|
+
provider: string;
|
|
80
|
+
nonce: string;
|
|
81
|
+
attestation: { headers: Record<string, string> };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
expect(body.provider).toBe('aws-iam');
|
|
85
|
+
expect(body.nonce).toBe('nonce-from-oxy');
|
|
86
|
+
expect(body.attestation.headers['x-oxy-attestation-nonce']).toBe('nonce-from-oxy');
|
|
87
|
+
expect(body.attestation.headers.host).toBe('sts.amazonaws.com');
|
|
88
|
+
expect(body.attestation.headers['x-amz-security-token']).toBe(CREDENTIALS.Token);
|
|
89
|
+
// The property the verifier actually checks.
|
|
90
|
+
expect(body.attestation.headers.authorization).toMatch(
|
|
91
|
+
/^AWS4-HMAC-SHA256 Credential=ASIAEXAMPLE\/\d{8}\/us-east-1\/sts\/aws4_request, SignedHeaders=[^,]*x-oxy-attestation-nonce[^,]*, Signature=[0-9a-f]{64}$/,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('never sends the signed request to AWS itself', async () => {
|
|
96
|
+
const { calls, fetchImpl } = harness();
|
|
97
|
+
|
|
98
|
+
await requestWorkloadServiceToken({ baseUrl: 'https://api.oxy.so', fetch: fetchImpl });
|
|
99
|
+
|
|
100
|
+
expect(calls.some((call) => call.url.includes('sts.amazonaws.com'))).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('carries no credential material into the request body', async () => {
|
|
104
|
+
const { calls, fetchImpl } = harness();
|
|
105
|
+
|
|
106
|
+
await requestWorkloadServiceToken({ baseUrl: 'https://api.oxy.so', fetch: fetchImpl });
|
|
107
|
+
|
|
108
|
+
const exchange = calls.find((call) => call.url.endsWith('/auth/service-token/workload'));
|
|
109
|
+
expect(String(exchange?.init?.body)).not.toContain(CREDENTIALS.SecretAccessKey);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('throws rather than returning an empty token when Oxy refuses', async () => {
|
|
113
|
+
const { fetchImpl } = harness({ tokenStatus: 403 });
|
|
114
|
+
|
|
115
|
+
await expect(
|
|
116
|
+
requestWorkloadServiceToken({ baseUrl: 'https://api.oxy.so', fetch: fetchImpl }),
|
|
117
|
+
).rejects.toThrow(/refused the workload attestation \(403\)/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('throws when the process has no identity to prove', async () => {
|
|
121
|
+
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '';
|
|
122
|
+
const { fetchImpl } = harness();
|
|
123
|
+
|
|
124
|
+
await expect(
|
|
125
|
+
requestWorkloadServiceToken({ baseUrl: 'https://api.oxy.so', fetch: fetchImpl }),
|
|
126
|
+
).rejects.toThrow(/cannot attest its identity/);
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/server/index.ts
CHANGED
|
@@ -168,3 +168,12 @@ export { createEcosystemTraffic } from './traffic';
|
|
|
168
168
|
export type { EcosystemTrafficOptions } from './traffic';
|
|
169
169
|
|
|
170
170
|
export { observeNodeHttp, withoutNodeHttpObservation } from './trafficNodeHttp';
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Workload identity (ADR 0026): a first-party service asks Oxy for a service
|
|
174
|
+
* token by proving what it IS, with no api key and no secret. Server-only, and
|
|
175
|
+
* exported from this subpath rather than the root barrel because it reaches the
|
|
176
|
+
* container credentials endpoint.
|
|
177
|
+
*/
|
|
178
|
+
export { canAttestWorkloadIdentity, requestWorkloadServiceToken } from './workloadIdentity';
|
|
179
|
+
export type { WorkloadServiceToken, WorkloadServiceTokenOptions } from './workloadIdentity';
|
|
@@ -360,6 +360,14 @@ const MAX_INLINE_SCRIPT_HASHES = 8;
|
|
|
360
360
|
export interface OxyPagesHeadersOptions {
|
|
361
361
|
/** Per-app additions merged into {@link OXY_CSP_BASELINE}. */
|
|
362
362
|
csp?: OxyCspExtensions;
|
|
363
|
+
/**
|
|
364
|
+
* An origin where people authenticate, consent or recover (`auth.oxy.so`,
|
|
365
|
+
* ADR 0024 D1). The Cloudflare Insights beacon — injected by the edge, not by
|
|
366
|
+
* the app — is REMOVED from `script-src` and `connect-src`, so third-party
|
|
367
|
+
* measurement cannot run there even when the zone has Web Analytics enabled.
|
|
368
|
+
* A structural block in the policy the page is served with, not a build flag.
|
|
369
|
+
*/
|
|
370
|
+
sensitive?: boolean;
|
|
363
371
|
/**
|
|
364
372
|
* Emit `Strict-Transport-Security` (default `true`). Cloudflare Pages serves
|
|
365
373
|
* HTTPS only, so static deploys should keep this on.
|
|
@@ -399,13 +407,18 @@ export function buildOxyPagesHeaders(options: OxyPagesHeadersOptions = {}): stri
|
|
|
399
407
|
);
|
|
400
408
|
}
|
|
401
409
|
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
: { ...options.csp, scriptSrc: [...(options.csp?.scriptSrc ?? []), ...hashes] },
|
|
407
|
-
),
|
|
410
|
+
const directives = buildOxyCspDirectives(
|
|
411
|
+
hashes.length === 0
|
|
412
|
+
? options.csp
|
|
413
|
+
: { ...options.csp, scriptSrc: [...(options.csp?.scriptSrc ?? []), ...hashes] },
|
|
408
414
|
);
|
|
415
|
+
if (options.sensitive) {
|
|
416
|
+
const beacon = new Set([CLOUDFLARE_INSIGHTS_SCRIPT_ORIGIN, CLOUDFLARE_INSIGHTS_REPORT_ORIGIN]);
|
|
417
|
+
for (const name of ['script-src', 'connect-src']) {
|
|
418
|
+
directives[name] = (directives[name] ?? []).filter((source) => !beacon.has(source));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const csp = formatOxyCspPolicy(directives);
|
|
409
422
|
const lines = [
|
|
410
423
|
'/*',
|
|
411
424
|
` Content-Security-Policy: ${csp}`,
|
package/src/server/traffic.ts
CHANGED
|
@@ -3,6 +3,12 @@ import { observeTrafficSocket, observeTrafficWebSocket, type TrafficWebSocket, t
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import type { RequestHandler } from 'express';
|
|
5
5
|
import { OxyServices } from '../OxyServices';
|
|
6
|
+
// Static, unlike the lazy import in `mixins/OxyServices.auth.ts`: that module is
|
|
7
|
+
// reachable from the root barrel, which ships to React Native, so pulling
|
|
8
|
+
// `node:crypto` in eagerly there would cost every mobile bundle. This file is
|
|
9
|
+
// server-only and the server barrel already exports `./workloadIdentity`, so
|
|
10
|
+
// there is nothing here for a phone to load.
|
|
11
|
+
import { canAttestWorkloadIdentity } from './workloadIdentity';
|
|
6
12
|
import { createTrafficCollector, instrumentTrafficFetch, trafficMiddleware, type TrafficFlow, type ServiceEndpoint, resolveOxyServiceEndpoint, infrastructureLocation } from '@oxy.so/telemetry/collector';
|
|
7
13
|
|
|
8
14
|
export interface EcosystemTrafficOptions {
|
|
@@ -36,8 +42,32 @@ export function createEcosystemTraffic(options: EcosystemTrafficOptions): {
|
|
|
36
42
|
}
|
|
37
43
|
const apiKey = activityKey || process.env.OXY_SERVICE_API_KEY;
|
|
38
44
|
const apiSecret = activitySecret || process.env.OXY_SERVICE_API_SECRET;
|
|
39
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Having no key pair is a legitimate configuration now — but only where this
|
|
47
|
+
* process can prove what it IS (ADR 0026).
|
|
48
|
+
*
|
|
49
|
+
* This used to throw on a missing pair, full stop. Every service in the fleet
|
|
50
|
+
* boots with `OXY_ECOSYSTEM_ACTIVITY_ENABLED=true`, so deleting
|
|
51
|
+
* `OXY_SERVICE_API_KEY` and `OXY_SERVICE_API_SECRET` from a task definition —
|
|
52
|
+
* which is the entire migration ADR 0026 asks for — killed the process at
|
|
53
|
+
* boot. The ECS circuit breaker then rolled the deploy back and reported the
|
|
54
|
+
* service stable, so the operator saw a rollback with nothing naming its
|
|
55
|
+
* cause. That is what one line cost, multiplied by fourteen services holding
|
|
56
|
+
* a secret they no longer need.
|
|
57
|
+
*
|
|
58
|
+
* What is still refused: nothing to authenticate with AND nothing to attest.
|
|
59
|
+
* That is a laptop or a CI box, where the container credentials endpoint is
|
|
60
|
+
* not going to turn up later, and the honest answer is to say so at boot
|
|
61
|
+
* rather than to fail on the first heartbeat.
|
|
62
|
+
*/
|
|
63
|
+
if (!options.credential && (!apiKey || !apiSecret) && !canAttestWorkloadIdentity()) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'Ecosystem activity needs one of: an OXY_ACTIVITY_API_KEY and OXY_ACTIVITY_API_SECRET pair, an OXY_SERVICE_API_KEY and OXY_SERVICE_API_SECRET pair, or a workload identity this process can attest (ADR 0026).',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
40
68
|
const oxy = new OxyServices({ baseURL });
|
|
69
|
+
// Left unconfigured when there is no pair on purpose: `getServiceToken()`
|
|
70
|
+
// attests instead of being handed half a credential to fail on.
|
|
41
71
|
if (apiKey && apiSecret) oxy.configureServiceAuth(apiKey, apiSecret);
|
|
42
72
|
const credential = options.credential ?? (() => oxy.getServiceToken());
|
|
43
73
|
const fetcher = globalThis.fetch.bind(globalThis);
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Asking Oxy for a service token by proving what this process IS (ADR 0026).
|
|
5
|
+
*
|
|
6
|
+
* The other half of the credential-free path: `packages/api` verifies the proof,
|
|
7
|
+
* this builds one. A first-party service that calls
|
|
8
|
+
* {@link requestWorkloadServiceToken} needs no api key, no secret and no entry in
|
|
9
|
+
* a parameter store — only to be running as itself.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this signs SigV4 by hand
|
|
12
|
+
*
|
|
13
|
+
* The attestation is a signed `GetCallerIdentity` request that is never sent to
|
|
14
|
+
* AWS by us; Oxy replays it. Producing one needs credentials and a signature,
|
|
15
|
+
* and the obvious way to get both is the AWS SDK — which this package must not
|
|
16
|
+
* depend on. `@oxy.so/core` ships to React Native and Expo, where several
|
|
17
|
+
* megabytes of AWS client for a call that only ever happens on a server is not a
|
|
18
|
+
* trade worth making, and the package rules keep optional heavyweights out of
|
|
19
|
+
* the root barrel for exactly this reason.
|
|
20
|
+
*
|
|
21
|
+
* So: credentials come from the container credentials endpoint ECS already
|
|
22
|
+
* exposes to every task, and the signature is ~40 lines of HMAC out of
|
|
23
|
+
* `node:crypto`. No dependency, and nothing here is AWS-specific above
|
|
24
|
+
* {@link awsContainerAttestation} — a second provider is another function with
|
|
25
|
+
* the same shape.
|
|
26
|
+
*
|
|
27
|
+
* ## What this never does
|
|
28
|
+
*
|
|
29
|
+
* It never sends the signed request to AWS. It never logs a credential, a
|
|
30
|
+
* signature or a token. It caches the TOKEN (short-lived, from Oxy) and never
|
|
31
|
+
* the AWS credentials, which the endpoint hands out fresh anyway.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The header Oxy's verifier requires inside the signature. Must match `ATTESTATION_NONCE_HEADER`. */
|
|
35
|
+
const NONCE_HEADER = 'x-oxy-attestation-nonce';
|
|
36
|
+
const STS_HOST = 'sts.amazonaws.com';
|
|
37
|
+
const STS_REGION = 'us-east-1';
|
|
38
|
+
const STS_BODY = 'Action=GetCallerIdentity&Version=2011-06-15';
|
|
39
|
+
|
|
40
|
+
export interface WorkloadServiceTokenOptions {
|
|
41
|
+
/** Oxy's base URL, e.g. `https://api.oxy.so`. */
|
|
42
|
+
baseUrl: string;
|
|
43
|
+
/** Injected in tests; defaults to the global. */
|
|
44
|
+
fetch?: typeof fetch;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface WorkloadServiceToken {
|
|
48
|
+
token: string;
|
|
49
|
+
/** Seconds from now, as Oxy reported it. */
|
|
50
|
+
expiresIn: number;
|
|
51
|
+
appName: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Credentials as the ECS container credentials endpoint returns them. */
|
|
55
|
+
interface ContainerCredentials {
|
|
56
|
+
AccessKeyId: string;
|
|
57
|
+
SecretAccessKey: string;
|
|
58
|
+
Token?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether this process can attest at all.
|
|
63
|
+
*
|
|
64
|
+
* The relative URI is set by ECS on every task and by nothing else, so its
|
|
65
|
+
* presence is the honest test for "we can prove what we are here". A local
|
|
66
|
+
* checkout has no attestation to offer and must fall back to a credential.
|
|
67
|
+
*/
|
|
68
|
+
export function canAttestWorkloadIdentity(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
69
|
+
return Boolean(env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || env.AWS_CONTAINER_CREDENTIALS_FULL_URI);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function containerCredentials(fetchImpl: typeof fetch): Promise<ContainerCredentials> {
|
|
73
|
+
const relative = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
|
|
74
|
+
const url = relative
|
|
75
|
+
? `http://169.254.170.2${relative}`
|
|
76
|
+
: (process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI as string);
|
|
77
|
+
if (!url) {
|
|
78
|
+
throw new Error('No container credentials endpoint: this process cannot attest its identity.');
|
|
79
|
+
}
|
|
80
|
+
const response = await fetchImpl(url, {
|
|
81
|
+
headers: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN
|
|
82
|
+
? { authorization: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN }
|
|
83
|
+
: {},
|
|
84
|
+
});
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
throw new Error(`The container credentials endpoint answered ${response.status}.`);
|
|
87
|
+
}
|
|
88
|
+
const credentials = (await response.json()) as ContainerCredentials;
|
|
89
|
+
if (!credentials.AccessKeyId || !credentials.SecretAccessKey) {
|
|
90
|
+
throw new Error('The container credentials endpoint answered without credentials.');
|
|
91
|
+
}
|
|
92
|
+
return credentials;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hmac(key: Buffer | string, data: string): Buffer {
|
|
96
|
+
return createHmac('sha256', key).update(data, 'utf8').digest();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function sha256Hex(value: string): string {
|
|
100
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Signs `GetCallerIdentity` with SigV4, binding the nonce into the signature.
|
|
105
|
+
*
|
|
106
|
+
* `SignedHeaders` includes the nonce header deliberately: Oxy refuses an
|
|
107
|
+
* attestation whose signature does not cover it, because a nonce the signature
|
|
108
|
+
* does not cover can be swapped by whoever captured the attestation.
|
|
109
|
+
*/
|
|
110
|
+
function signGetCallerIdentity(credentials: ContainerCredentials, nonce: string, now: Date): Record<string, string> {
|
|
111
|
+
const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
112
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
113
|
+
|
|
114
|
+
const headers: Record<string, string> = {
|
|
115
|
+
host: STS_HOST,
|
|
116
|
+
'x-amz-date': amzDate,
|
|
117
|
+
[NONCE_HEADER]: nonce,
|
|
118
|
+
};
|
|
119
|
+
if (credentials.Token) headers['x-amz-security-token'] = credentials.Token;
|
|
120
|
+
|
|
121
|
+
const signedHeaderNames = Object.keys(headers).sort();
|
|
122
|
+
const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]}\n`).join('');
|
|
123
|
+
const signedHeaders = signedHeaderNames.join(';');
|
|
124
|
+
|
|
125
|
+
const canonicalRequest = [
|
|
126
|
+
'POST',
|
|
127
|
+
'/',
|
|
128
|
+
'',
|
|
129
|
+
canonicalHeaders,
|
|
130
|
+
signedHeaders,
|
|
131
|
+
sha256Hex(STS_BODY),
|
|
132
|
+
].join('\n');
|
|
133
|
+
|
|
134
|
+
const scope = `${dateStamp}/${STS_REGION}/sts/aws4_request`;
|
|
135
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n');
|
|
136
|
+
|
|
137
|
+
const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.SecretAccessKey}`, dateStamp), STS_REGION), 'sts'), 'aws4_request');
|
|
138
|
+
const signature = createHmac('sha256', signingKey).update(stringToSign, 'utf8').digest('hex');
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
...headers,
|
|
142
|
+
authorization:
|
|
143
|
+
`AWS4-HMAC-SHA256 Credential=${credentials.AccessKeyId}/${scope}, ` +
|
|
144
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The AWS attestation: signed headers, and nothing else. */
|
|
149
|
+
async function awsContainerAttestation(fetchImpl: typeof fetch, nonce: string): Promise<{ headers: Record<string, string> }> {
|
|
150
|
+
const credentials = await containerCredentials(fetchImpl);
|
|
151
|
+
return { headers: signGetCallerIdentity(credentials, nonce, new Date()) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Exchanges this process's identity for an Oxy service token.
|
|
156
|
+
*
|
|
157
|
+
* Two round trips by design (see ADR 0026): a nonce, then an attestation that
|
|
158
|
+
* signs it. The caller caches the token — it lives an hour — rather than doing
|
|
159
|
+
* this per request.
|
|
160
|
+
*
|
|
161
|
+
* Failures are thrown, never swallowed into an empty token: a caller that
|
|
162
|
+
* cannot prove what it is must not go on to make an unauthenticated request and
|
|
163
|
+
* discover the problem as a 401 somewhere else.
|
|
164
|
+
*/
|
|
165
|
+
export async function requestWorkloadServiceToken(
|
|
166
|
+
options: WorkloadServiceTokenOptions,
|
|
167
|
+
): Promise<WorkloadServiceToken> {
|
|
168
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
169
|
+
const base = options.baseUrl.replace(/\/+$/, '');
|
|
170
|
+
|
|
171
|
+
const challengeResponse = await fetchImpl(`${base}/auth/service-token/workload/challenge`, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: { 'content-type': 'application/json' },
|
|
174
|
+
});
|
|
175
|
+
if (!challengeResponse.ok) {
|
|
176
|
+
throw new Error(`Oxy refused to issue a workload challenge (${challengeResponse.status}).`);
|
|
177
|
+
}
|
|
178
|
+
const challenge = unwrap<{ nonce?: string }>(await challengeResponse.json());
|
|
179
|
+
if (!challenge?.nonce) {
|
|
180
|
+
throw new Error('Oxy issued a workload challenge with no nonce.');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const attestation = await awsContainerAttestation(fetchImpl, challenge.nonce);
|
|
184
|
+
|
|
185
|
+
const tokenResponse = await fetchImpl(`${base}/auth/service-token/workload`, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: { 'content-type': 'application/json' },
|
|
188
|
+
body: JSON.stringify({ provider: 'aws-iam', nonce: challenge.nonce, attestation }),
|
|
189
|
+
});
|
|
190
|
+
if (!tokenResponse.ok) {
|
|
191
|
+
// The body carries a bounded `reason`; the token never travels on a failure,
|
|
192
|
+
// so there is nothing here that must not be seen.
|
|
193
|
+
throw new Error(`Oxy refused the workload attestation (${tokenResponse.status}).`);
|
|
194
|
+
}
|
|
195
|
+
const granted = unwrap<Partial<WorkloadServiceToken>>(await tokenResponse.json());
|
|
196
|
+
if (!granted?.token || typeof granted.expiresIn !== 'number') {
|
|
197
|
+
throw new Error('Oxy answered the attestation without a usable token.');
|
|
198
|
+
}
|
|
199
|
+
return { token: granted.token, expiresIn: granted.expiresIn, appName: granted.appName ?? '' };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Oxy wraps successful bodies in `{ data }`; older routes answer flat. */
|
|
203
|
+
function unwrap<T>(body: unknown): T | null {
|
|
204
|
+
if (typeof body !== 'object' || body === null) return null;
|
|
205
|
+
const envelope = body as { data?: unknown };
|
|
206
|
+
return (typeof envelope.data === 'object' && envelope.data !== null ? envelope.data : body) as T;
|
|
207
|
+
}
|
|
@@ -57,17 +57,23 @@ interface RefreshMockOverrides {
|
|
|
57
57
|
signInWithSharedIdentity?: OxyServices['signInWithSharedIdentity'];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
function makeOxy(
|
|
60
|
+
function makeOxy(
|
|
61
|
+
overrides: RefreshMockOverrides = {},
|
|
62
|
+
): { oxy: OxyServices; setTokens: jest.Mock; noteRefreshRateLimited: jest.Mock } {
|
|
61
63
|
const setTokens = jest.fn();
|
|
64
|
+
const noteRefreshRateLimited = jest.fn();
|
|
62
65
|
const oxy = {
|
|
63
66
|
setTokens,
|
|
64
67
|
mintFromDeviceSecret: overrides.mintFromDeviceSecret ?? (async () => MINT),
|
|
65
68
|
signInWithSharedIdentity: overrides.signInWithSharedIdentity ?? (async () => null),
|
|
66
69
|
// The rotating mint runs under the client's process-wide single-flight; the
|
|
67
70
|
// arm reaches for it via `oxy.httpService.runSingleFlightDeviceSecretMint`.
|
|
68
|
-
httpService: {
|
|
71
|
+
httpService: {
|
|
72
|
+
runSingleFlightDeviceSecretMint: makeMintSingleFlight(),
|
|
73
|
+
noteRefreshRateLimited,
|
|
74
|
+
},
|
|
69
75
|
} as unknown as OxyServices;
|
|
70
|
-
return { oxy, setTokens };
|
|
76
|
+
return { oxy, setTokens, noteRefreshRateLimited };
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
describe('refreshPersistedSession — arm 1 (device-secret mint)', () => {
|
|
@@ -179,6 +185,50 @@ describe('refreshPersistedSession — arm 1 (device-secret mint)', () => {
|
|
|
179
185
|
expect(await store.load()).toEqual(STORED);
|
|
180
186
|
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
181
187
|
});
|
|
188
|
+
|
|
189
|
+
it('KEEPS the store on a 429 and does NOT fall through to shared-key — a rationed mint never judged the secret', async () => {
|
|
190
|
+
const store = createMemoryAuthStateStore();
|
|
191
|
+
await store.save(STORED);
|
|
192
|
+
const signInWithSharedIdentity = jest.fn(async () => null);
|
|
193
|
+
const { oxy } = makeOxy({
|
|
194
|
+
mintFromDeviceSecret: async () => {
|
|
195
|
+
throw Object.assign(new Error('Too many requests'), { status: 429 });
|
|
196
|
+
},
|
|
197
|
+
signInWithSharedIdentity,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: true })).toBeNull();
|
|
201
|
+
expect(await store.load()).toEqual(STORED);
|
|
202
|
+
expect(signInWithSharedIdentity).not.toHaveBeenCalled();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('tells HttpService to lengthen the refresh cooldown when the mint is rate limited', async () => {
|
|
206
|
+
const store = createMemoryAuthStateStore();
|
|
207
|
+
await store.save(STORED);
|
|
208
|
+
const { oxy, noteRefreshRateLimited } = makeOxy({
|
|
209
|
+
mintFromDeviceSecret: async () => {
|
|
210
|
+
throw Object.assign(new Error('Too many requests'), { status: 429 });
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
215
|
+
|
|
216
|
+
expect(noteRefreshRateLimited).toHaveBeenCalledTimes(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('does NOT lengthen the cooldown for failures the server did not ration', async () => {
|
|
220
|
+
const store = createMemoryAuthStateStore();
|
|
221
|
+
await store.save(STORED);
|
|
222
|
+
const { oxy, noteRefreshRateLimited } = makeOxy({
|
|
223
|
+
mintFromDeviceSecret: async () => {
|
|
224
|
+
throw Object.assign(new Error('server'), { status: 500 });
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
await refreshPersistedSession({ oxy, store, allowSharedKeyFallback: false });
|
|
229
|
+
|
|
230
|
+
expect(noteRefreshRateLimited).not.toHaveBeenCalled();
|
|
231
|
+
});
|
|
182
232
|
});
|
|
183
233
|
|
|
184
234
|
describe('refreshPersistedSession — arm 2 (native shared-key fallback)', () => {
|
package/src/session/refresh.ts
CHANGED
|
@@ -159,6 +159,18 @@ export async function refreshDeviceSecretArm(deps: {
|
|
|
159
159
|
})
|
|
160
160
|
: await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
|
|
161
161
|
} catch (error) {
|
|
162
|
+
// 429 keeps the credential like any other transient failure — the secret
|
|
163
|
+
// was never judged — but it must NOT retry on the transient cadence. The
|
|
164
|
+
// request-driven lanes (request-time preflight, 401 retry) reattempt every
|
|
165
|
+
// second once the access token is expired, which is 60 mints/min against a
|
|
166
|
+
// 30/min server budget: the client's own retries then hold the limiter
|
|
167
|
+
// tripped and the session cannot recover while the app keeps making
|
|
168
|
+
// requests. Telling HttpService lengthens the next cooldown to one attempt
|
|
169
|
+
// per limiter window, which lets the budget drain and the session heal.
|
|
170
|
+
if (extractErrorStatus(error) === 429) {
|
|
171
|
+
oxy.httpService.noteRefreshRateLimited();
|
|
172
|
+
return { status: 'transient' };
|
|
173
|
+
}
|
|
162
174
|
if (extractErrorStatus(error) === 401) {
|
|
163
175
|
// Structural read (not `instanceof Error`): the thrown value can be a
|
|
164
176
|
// plain ApiError-shaped object or come from another realm.
|