@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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Asking Oxy for a service token by proving what this process IS (ADR 0026).
|
|
4
|
+
*
|
|
5
|
+
* The other half of the credential-free path: `packages/api` verifies the proof,
|
|
6
|
+
* this builds one. A first-party service that calls
|
|
7
|
+
* {@link requestWorkloadServiceToken} needs no api key, no secret and no entry in
|
|
8
|
+
* a parameter store — only to be running as itself.
|
|
9
|
+
*
|
|
10
|
+
* ## Why this signs SigV4 by hand
|
|
11
|
+
*
|
|
12
|
+
* The attestation is a signed `GetCallerIdentity` request that is never sent to
|
|
13
|
+
* AWS by us; Oxy replays it. Producing one needs credentials and a signature,
|
|
14
|
+
* and the obvious way to get both is the AWS SDK — which this package must not
|
|
15
|
+
* depend on. `@oxy.so/core` ships to React Native and Expo, where several
|
|
16
|
+
* megabytes of AWS client for a call that only ever happens on a server is not a
|
|
17
|
+
* trade worth making, and the package rules keep optional heavyweights out of
|
|
18
|
+
* the root barrel for exactly this reason.
|
|
19
|
+
*
|
|
20
|
+
* So: credentials come from the container credentials endpoint ECS already
|
|
21
|
+
* exposes to every task, and the signature is ~40 lines of HMAC out of
|
|
22
|
+
* `node:crypto`. No dependency, and nothing here is AWS-specific above
|
|
23
|
+
* {@link awsContainerAttestation} — a second provider is another function with
|
|
24
|
+
* the same shape.
|
|
25
|
+
*
|
|
26
|
+
* ## What this never does
|
|
27
|
+
*
|
|
28
|
+
* It never sends the signed request to AWS. It never logs a credential, a
|
|
29
|
+
* signature or a token. It caches the TOKEN (short-lived, from Oxy) and never
|
|
30
|
+
* the AWS credentials, which the endpoint hands out fresh anyway.
|
|
31
|
+
*/
|
|
32
|
+
/** The header Oxy's verifier requires inside the signature. Must match `ATTESTATION_NONCE_HEADER`. */
|
|
33
|
+
const NONCE_HEADER = 'x-oxy-attestation-nonce';
|
|
34
|
+
const STS_HOST = 'sts.amazonaws.com';
|
|
35
|
+
const STS_REGION = 'us-east-1';
|
|
36
|
+
const STS_BODY = 'Action=GetCallerIdentity&Version=2011-06-15';
|
|
37
|
+
/**
|
|
38
|
+
* Whether this process can attest at all.
|
|
39
|
+
*
|
|
40
|
+
* The relative URI is set by ECS on every task and by nothing else, so its
|
|
41
|
+
* presence is the honest test for "we can prove what we are here". A local
|
|
42
|
+
* checkout has no attestation to offer and must fall back to a credential.
|
|
43
|
+
*/
|
|
44
|
+
export function canAttestWorkloadIdentity(env = process.env) {
|
|
45
|
+
return Boolean(env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || env.AWS_CONTAINER_CREDENTIALS_FULL_URI);
|
|
46
|
+
}
|
|
47
|
+
async function containerCredentials(fetchImpl) {
|
|
48
|
+
const relative = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
|
|
49
|
+
const url = relative
|
|
50
|
+
? `http://169.254.170.2${relative}`
|
|
51
|
+
: process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
|
|
52
|
+
if (!url) {
|
|
53
|
+
throw new Error('No container credentials endpoint: this process cannot attest its identity.');
|
|
54
|
+
}
|
|
55
|
+
const response = await fetchImpl(url, {
|
|
56
|
+
headers: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN
|
|
57
|
+
? { authorization: process.env.AWS_CONTAINER_AUTHORIZATION_TOKEN }
|
|
58
|
+
: {},
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
throw new Error(`The container credentials endpoint answered ${response.status}.`);
|
|
62
|
+
}
|
|
63
|
+
const credentials = (await response.json());
|
|
64
|
+
if (!credentials.AccessKeyId || !credentials.SecretAccessKey) {
|
|
65
|
+
throw new Error('The container credentials endpoint answered without credentials.');
|
|
66
|
+
}
|
|
67
|
+
return credentials;
|
|
68
|
+
}
|
|
69
|
+
function hmac(key, data) {
|
|
70
|
+
return createHmac('sha256', key).update(data, 'utf8').digest();
|
|
71
|
+
}
|
|
72
|
+
function sha256Hex(value) {
|
|
73
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Signs `GetCallerIdentity` with SigV4, binding the nonce into the signature.
|
|
77
|
+
*
|
|
78
|
+
* `SignedHeaders` includes the nonce header deliberately: Oxy refuses an
|
|
79
|
+
* attestation whose signature does not cover it, because a nonce the signature
|
|
80
|
+
* does not cover can be swapped by whoever captured the attestation.
|
|
81
|
+
*/
|
|
82
|
+
function signGetCallerIdentity(credentials, nonce, now) {
|
|
83
|
+
const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
84
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
85
|
+
const headers = {
|
|
86
|
+
host: STS_HOST,
|
|
87
|
+
'x-amz-date': amzDate,
|
|
88
|
+
[NONCE_HEADER]: nonce,
|
|
89
|
+
};
|
|
90
|
+
if (credentials.Token)
|
|
91
|
+
headers['x-amz-security-token'] = credentials.Token;
|
|
92
|
+
const signedHeaderNames = Object.keys(headers).sort();
|
|
93
|
+
const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]}\n`).join('');
|
|
94
|
+
const signedHeaders = signedHeaderNames.join(';');
|
|
95
|
+
const canonicalRequest = [
|
|
96
|
+
'POST',
|
|
97
|
+
'/',
|
|
98
|
+
'',
|
|
99
|
+
canonicalHeaders,
|
|
100
|
+
signedHeaders,
|
|
101
|
+
sha256Hex(STS_BODY),
|
|
102
|
+
].join('\n');
|
|
103
|
+
const scope = `${dateStamp}/${STS_REGION}/sts/aws4_request`;
|
|
104
|
+
const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n');
|
|
105
|
+
const signingKey = hmac(hmac(hmac(hmac(`AWS4${credentials.SecretAccessKey}`, dateStamp), STS_REGION), 'sts'), 'aws4_request');
|
|
106
|
+
const signature = createHmac('sha256', signingKey).update(stringToSign, 'utf8').digest('hex');
|
|
107
|
+
return {
|
|
108
|
+
...headers,
|
|
109
|
+
authorization: `AWS4-HMAC-SHA256 Credential=${credentials.AccessKeyId}/${scope}, ` +
|
|
110
|
+
`SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/** The AWS attestation: signed headers, and nothing else. */
|
|
114
|
+
async function awsContainerAttestation(fetchImpl, nonce) {
|
|
115
|
+
const credentials = await containerCredentials(fetchImpl);
|
|
116
|
+
return { headers: signGetCallerIdentity(credentials, nonce, new Date()) };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Exchanges this process's identity for an Oxy service token.
|
|
120
|
+
*
|
|
121
|
+
* Two round trips by design (see ADR 0026): a nonce, then an attestation that
|
|
122
|
+
* signs it. The caller caches the token — it lives an hour — rather than doing
|
|
123
|
+
* this per request.
|
|
124
|
+
*
|
|
125
|
+
* Failures are thrown, never swallowed into an empty token: a caller that
|
|
126
|
+
* cannot prove what it is must not go on to make an unauthenticated request and
|
|
127
|
+
* discover the problem as a 401 somewhere else.
|
|
128
|
+
*/
|
|
129
|
+
export async function requestWorkloadServiceToken(options) {
|
|
130
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
131
|
+
const base = options.baseUrl.replace(/\/+$/, '');
|
|
132
|
+
const challengeResponse = await fetchImpl(`${base}/auth/service-token/workload/challenge`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'content-type': 'application/json' },
|
|
135
|
+
});
|
|
136
|
+
if (!challengeResponse.ok) {
|
|
137
|
+
throw new Error(`Oxy refused to issue a workload challenge (${challengeResponse.status}).`);
|
|
138
|
+
}
|
|
139
|
+
const challenge = unwrap(await challengeResponse.json());
|
|
140
|
+
if (!challenge?.nonce) {
|
|
141
|
+
throw new Error('Oxy issued a workload challenge with no nonce.');
|
|
142
|
+
}
|
|
143
|
+
const attestation = await awsContainerAttestation(fetchImpl, challenge.nonce);
|
|
144
|
+
const tokenResponse = await fetchImpl(`${base}/auth/service-token/workload`, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { 'content-type': 'application/json' },
|
|
147
|
+
body: JSON.stringify({ provider: 'aws-iam', nonce: challenge.nonce, attestation }),
|
|
148
|
+
});
|
|
149
|
+
if (!tokenResponse.ok) {
|
|
150
|
+
// The body carries a bounded `reason`; the token never travels on a failure,
|
|
151
|
+
// so there is nothing here that must not be seen.
|
|
152
|
+
throw new Error(`Oxy refused the workload attestation (${tokenResponse.status}).`);
|
|
153
|
+
}
|
|
154
|
+
const granted = unwrap(await tokenResponse.json());
|
|
155
|
+
if (!granted?.token || typeof granted.expiresIn !== 'number') {
|
|
156
|
+
throw new Error('Oxy answered the attestation without a usable token.');
|
|
157
|
+
}
|
|
158
|
+
return { token: granted.token, expiresIn: granted.expiresIn, appName: granted.appName ?? '' };
|
|
159
|
+
}
|
|
160
|
+
/** Oxy wraps successful bodies in `{ data }`; older routes answer flat. */
|
|
161
|
+
function unwrap(body) {
|
|
162
|
+
if (typeof body !== 'object' || body === null)
|
|
163
|
+
return null;
|
|
164
|
+
const envelope = body;
|
|
165
|
+
return (typeof envelope.data === 'object' && envelope.data !== null ? envelope.data : body);
|
|
166
|
+
}
|
|
@@ -72,6 +72,18 @@ export async function refreshDeviceSecretArm(deps) {
|
|
|
72
72
|
: await oxy.mintFromDeviceSecret(persisted.deviceId, persisted.deviceSecret);
|
|
73
73
|
}
|
|
74
74
|
catch (error) {
|
|
75
|
+
// 429 keeps the credential like any other transient failure — the secret
|
|
76
|
+
// was never judged — but it must NOT retry on the transient cadence. The
|
|
77
|
+
// request-driven lanes (request-time preflight, 401 retry) reattempt every
|
|
78
|
+
// second once the access token is expired, which is 60 mints/min against a
|
|
79
|
+
// 30/min server budget: the client's own retries then hold the limiter
|
|
80
|
+
// tripped and the session cannot recover while the app keeps making
|
|
81
|
+
// requests. Telling HttpService lengthens the next cooldown to one attempt
|
|
82
|
+
// per limiter window, which lets the budget drain and the session heal.
|
|
83
|
+
if (extractErrorStatus(error) === 429) {
|
|
84
|
+
oxy.httpService.noteRefreshRateLimited();
|
|
85
|
+
return { status: 'transient' };
|
|
86
|
+
}
|
|
75
87
|
if (extractErrorStatus(error) === 401) {
|
|
76
88
|
// Structural read (not `instanceof Error`): the thrown value can be a
|
|
77
89
|
// plain ApiError-shaped object or come from another realm.
|