@oxy.so/core 1.5.0 → 1.6.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/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/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/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/OxyServices.d.ts +3 -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/OxyServices.utility.d.ts +2 -0
- 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 +1 -1
- package/src/HttpService.ts +74 -9
- package/src/OxyServices.ts +9 -0
- package/src/__tests__/httpServiceRateLimitedRefresh.test.ts +160 -0
- package/src/__tests__/publicInterfaceParity.test.ts +75 -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/OxyServices.utility.ts +2 -0
- 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__/workloadIdentity.test.ts +128 -0
- package/src/server/index.ts +9 -0
- package/src/server/securityHeaders.ts +19 -6
- 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
|
@@ -34,7 +34,6 @@ import { OxyServicesNodesMixin } from './OxyServices.nodes';
|
|
|
34
34
|
import { OxyServicesFollowGraphMixin } from './OxyServices.followGraph';
|
|
35
35
|
import { OxyServicesInferenceMixin } from './OxyServices.inference';
|
|
36
36
|
import { OxyServicesDeviceBootMixin } from './OxyServices.deviceBoot';
|
|
37
|
-
import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
|
|
38
37
|
/**
|
|
39
38
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
40
39
|
* `composeOxyServices()` produces a class whose instances expose all of
|
|
@@ -44,7 +43,7 @@ import { OxyServicesDeviceTransferMixin } from './OxyServices.deviceTransfer';
|
|
|
44
43
|
* If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
|
|
45
44
|
* are visible without a cast.
|
|
46
45
|
*/
|
|
47
|
-
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAgencyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesStoreMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesChainsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesInferenceMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof
|
|
46
|
+
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityBackupMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesConnectedAppsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAgencyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesStoreMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNotificationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesChainsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesNodesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFollowGraphMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesInferenceMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeviceBootMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
48
47
|
/**
|
|
49
48
|
* Constructor type for the fully composed mixin pipeline. Each mixin returns
|
|
50
49
|
* a new constructor that augments its input; reducing across the pipeline
|
|
@@ -38,3 +38,11 @@ export { isOfficialWebOrigin } from '../utils/officialOrigins';
|
|
|
38
38
|
export { createEcosystemTraffic } from './traffic';
|
|
39
39
|
export type { EcosystemTrafficOptions } from './traffic';
|
|
40
40
|
export { observeNodeHttp, withoutNodeHttpObservation } from './trafficNodeHttp';
|
|
41
|
+
/**
|
|
42
|
+
* Workload identity (ADR 0026): a first-party service asks Oxy for a service
|
|
43
|
+
* token by proving what it IS, with no api key and no secret. Server-only, and
|
|
44
|
+
* exported from this subpath rather than the root barrel because it reaches the
|
|
45
|
+
* container credentials endpoint.
|
|
46
|
+
*/
|
|
47
|
+
export { canAttestWorkloadIdentity, requestWorkloadServiceToken } from './workloadIdentity';
|
|
48
|
+
export type { WorkloadServiceToken, WorkloadServiceTokenOptions } from './workloadIdentity';
|
|
@@ -155,6 +155,14 @@ export declare function inlineScriptCspHash(source: string): string;
|
|
|
155
155
|
export interface OxyPagesHeadersOptions {
|
|
156
156
|
/** Per-app additions merged into {@link OXY_CSP_BASELINE}. */
|
|
157
157
|
csp?: OxyCspExtensions;
|
|
158
|
+
/**
|
|
159
|
+
* An origin where people authenticate, consent or recover (`auth.oxy.so`,
|
|
160
|
+
* ADR 0024 D1). The Cloudflare Insights beacon — injected by the edge, not by
|
|
161
|
+
* the app — is REMOVED from `script-src` and `connect-src`, so third-party
|
|
162
|
+
* measurement cannot run there even when the zone has Web Analytics enabled.
|
|
163
|
+
* A structural block in the policy the page is served with, not a build flag.
|
|
164
|
+
*/
|
|
165
|
+
sensitive?: boolean;
|
|
158
166
|
/**
|
|
159
167
|
* Emit `Strict-Transport-Security` (default `true`). Cloudflare Pages serves
|
|
160
168
|
* HTTPS only, so static deploys should keep this on.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface WorkloadServiceTokenOptions {
|
|
2
|
+
/** Oxy's base URL, e.g. `https://api.oxy.so`. */
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
/** Injected in tests; defaults to the global. */
|
|
5
|
+
fetch?: typeof fetch;
|
|
6
|
+
}
|
|
7
|
+
export interface WorkloadServiceToken {
|
|
8
|
+
token: string;
|
|
9
|
+
/** Seconds from now, as Oxy reported it. */
|
|
10
|
+
expiresIn: number;
|
|
11
|
+
appName: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Whether this process can attest at all.
|
|
15
|
+
*
|
|
16
|
+
* The relative URI is set by ECS on every task and by nothing else, so its
|
|
17
|
+
* presence is the honest test for "we can prove what we are here". A local
|
|
18
|
+
* checkout has no attestation to offer and must fall back to a credential.
|
|
19
|
+
*/
|
|
20
|
+
export declare function canAttestWorkloadIdentity(env?: NodeJS.ProcessEnv): boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Exchanges this process's identity for an Oxy service token.
|
|
23
|
+
*
|
|
24
|
+
* Two round trips by design (see ADR 0026): a nonce, then an attestation that
|
|
25
|
+
* signs it. The caller caches the token — it lives an hour — rather than doing
|
|
26
|
+
* this per request.
|
|
27
|
+
*
|
|
28
|
+
* Failures are thrown, never swallowed into an empty token: a caller that
|
|
29
|
+
* cannot prove what it is must not go on to make an unauthenticated request and
|
|
30
|
+
* discover the problem as a 401 somewhere else.
|
|
31
|
+
*/
|
|
32
|
+
export declare function requestWorkloadServiceToken(options: WorkloadServiceTokenOptions): Promise<WorkloadServiceToken>;
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -186,6 +186,34 @@ const TOKEN_REFRESH_COOLDOWN_MS = 15000;
|
|
|
186
186
|
*/
|
|
187
187
|
const EXPIRED_TOKEN_REFRESH_COOLDOWN_MS = 1000;
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Cooldown (ms) applied after the refresh endpoint answered 429 Too Many
|
|
191
|
+
* Requests. Overrides BOTH cooldowns above, because neither of them is about
|
|
192
|
+
* this failure.
|
|
193
|
+
*
|
|
194
|
+
* The other two ask "is the current token still usable?" and answer a server
|
|
195
|
+
* that is unreachable or erroring. A 429 is the opposite situation: the server
|
|
196
|
+
* is perfectly reachable and is telling this client, specifically, that it is
|
|
197
|
+
* asking too often. `EXPIRED_TOKEN_REFRESH_COOLDOWN_MS` then becomes actively
|
|
198
|
+
* harmful — an expired token drives the request-time preflight and the 401
|
|
199
|
+
* retry lanes at one mint per second (60/min) against a mint budget of 30/min,
|
|
200
|
+
* so the client's own retries hold the limiter tripped and it can never fall
|
|
201
|
+
* back under the ceiling. The limit stops being self-clearing and becomes
|
|
202
|
+
* self-sustaining for as long as the app keeps issuing requests.
|
|
203
|
+
*
|
|
204
|
+
* Sized to the mint limiter's own window (60s, `packages/api/src/routes/sessionDevice.ts`)
|
|
205
|
+
* so ONE attempt lands per window: enough to discover the moment the budget has
|
|
206
|
+
* drained, never enough to consume it. The per-device lockout answers 429 for
|
|
207
|
+
* far longer (15 min), and that is fine — one probe a minute against it is
|
|
208
|
+
* negligible traffic and the session recovers the instant the lockout lifts.
|
|
209
|
+
*
|
|
210
|
+
* NOT routed through the scheduler's exponential backoff: that backoff is
|
|
211
|
+
* effect-local to `startTokenRefreshScheduler` and governs only the scheduled
|
|
212
|
+
* lane, while the lanes that actually produce the storm here (preflight, 401
|
|
213
|
+
* retry) are request-driven and are throttled solely by this cooldown.
|
|
214
|
+
*/
|
|
215
|
+
const RATE_LIMITED_REFRESH_COOLDOWN_MS = 60_000;
|
|
216
|
+
|
|
189
217
|
/**
|
|
190
218
|
* Lead time (seconds) before access-token expiry at which a preflight refresh
|
|
191
219
|
* is triggered. A token within this window of `exp` is treated as effectively
|
|
@@ -293,6 +321,17 @@ export class HttpService {
|
|
|
293
321
|
* fixed deadline that could not shrink once the token expired mid-cooldown.
|
|
294
322
|
*/
|
|
295
323
|
private lastRefreshFailureAt = 0;
|
|
324
|
+
/**
|
|
325
|
+
* Whether the last refresh failure was the server answering 429. Selects
|
|
326
|
+
* {@link RATE_LIMITED_REFRESH_COOLDOWN_MS} over the two token-state cooldowns
|
|
327
|
+
* for the NEXT attempt, and is cleared by any success or any other failure.
|
|
328
|
+
*
|
|
329
|
+
* A boolean beside {@link lastRefreshFailureAt} rather than a stored deadline,
|
|
330
|
+
* for the same reason that field gives: the cooldown stays a function of the
|
|
331
|
+
* current state, so it can still shorten if the situation changes underneath
|
|
332
|
+
* it, and there is exactly one timestamp to reason about.
|
|
333
|
+
*/
|
|
334
|
+
private lastRefreshWasRateLimited = false;
|
|
296
335
|
private authRefreshHandler: AuthRefreshHandler | null = null;
|
|
297
336
|
private accessTokenProvider: AccessTokenProvider | null = null;
|
|
298
337
|
private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
@@ -1156,20 +1195,29 @@ export class HttpService {
|
|
|
1156
1195
|
return null;
|
|
1157
1196
|
}
|
|
1158
1197
|
|
|
1159
|
-
// Post-failure cooldown. A
|
|
1160
|
-
//
|
|
1161
|
-
// expired
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1198
|
+
// Post-failure cooldown. A 429 names its own window regardless of token
|
|
1199
|
+
// state — the server is reachable and is rationing this client, so retrying
|
|
1200
|
+
// at the expired-token rate would spend the very budget being waited on.
|
|
1201
|
+
// Otherwise a genuinely EXPIRED current token uses a much shorter cooldown
|
|
1202
|
+
// than a still-valid (proactive, near-expiry) one: an expired token is
|
|
1203
|
+
// unusable, so re-mint as soon as the endpoint is reachable again rather
|
|
1204
|
+
// than waiting out the full window while requests carry a stale bearer. All
|
|
1205
|
+
// three are measured from the last failure, so the moment a still-valid
|
|
1206
|
+
// token crosses `exp` mid-cooldown the shorter window applies.
|
|
1207
|
+
const cooldownMs = this.lastRefreshWasRateLimited
|
|
1208
|
+
? RATE_LIMITED_REFRESH_COOLDOWN_MS
|
|
1209
|
+
: this.isAccessTokenExpired()
|
|
1210
|
+
? EXPIRED_TOKEN_REFRESH_COOLDOWN_MS
|
|
1211
|
+
: TOKEN_REFRESH_COOLDOWN_MS;
|
|
1168
1212
|
if (Date.now() - this.lastRefreshFailureAt < cooldownMs) {
|
|
1169
1213
|
return null;
|
|
1170
1214
|
}
|
|
1171
1215
|
|
|
1172
1216
|
if (!this.tokenRefreshPromise) {
|
|
1217
|
+
// Cleared before the attempt, never after it: the handler reports a 429
|
|
1218
|
+
// by calling `noteRefreshRateLimited()` from INSIDE this call, so clearing
|
|
1219
|
+
// on the way out would discard the flag it just set.
|
|
1220
|
+
this.lastRefreshWasRateLimited = false;
|
|
1173
1221
|
this.tokenRefreshPromise = this.authRefreshHandler(reason)
|
|
1174
1222
|
.then((newToken) => {
|
|
1175
1223
|
if (!newToken) {
|
|
@@ -1183,6 +1231,7 @@ export class HttpService {
|
|
|
1183
1231
|
// A success clears the failure timestamp so the next refresh is never
|
|
1184
1232
|
// throttled by a stale cooldown.
|
|
1185
1233
|
this.lastRefreshFailureAt = 0;
|
|
1234
|
+
this.lastRefreshWasRateLimited = false;
|
|
1186
1235
|
this.logger.debug('Token refreshed via the auth refresh handler');
|
|
1187
1236
|
return newToken;
|
|
1188
1237
|
})
|
|
@@ -1199,6 +1248,22 @@ export class HttpService {
|
|
|
1199
1248
|
return this.tokenRefreshPromise;
|
|
1200
1249
|
}
|
|
1201
1250
|
|
|
1251
|
+
/**
|
|
1252
|
+
* Report that the refresh/mint endpoint answered 429 Too Many Requests, so the
|
|
1253
|
+
* next attempt waits {@link RATE_LIMITED_REFRESH_COOLDOWN_MS} instead of one of
|
|
1254
|
+
* the token-state cooldowns.
|
|
1255
|
+
*
|
|
1256
|
+
* Called by the refresh handler (`refreshDeviceSecretArm`) from inside the
|
|
1257
|
+
* `authRefreshHandler` call, because the handler's `Promise<string | null>`
|
|
1258
|
+
* contract cannot carry WHY a refresh failed and 429 is the one failure whose
|
|
1259
|
+
* correct retry interval is set by the server rather than by the token.
|
|
1260
|
+
*
|
|
1261
|
+
* Public because the handler lives in `session/refresh.ts`, not on this class.
|
|
1262
|
+
*/
|
|
1263
|
+
noteRefreshRateLimited(): void {
|
|
1264
|
+
this.lastRefreshWasRateLimited = true;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1202
1267
|
/**
|
|
1203
1268
|
* Whether the CURRENT stored access token is already past its `exp`. Drives
|
|
1204
1269
|
* the shorter post-failure refresh cooldown ({@link EXPIRED_TOKEN_REFRESH_COOLDOWN_MS}):
|
package/src/OxyServices.ts
CHANGED
|
@@ -61,6 +61,7 @@ import { AssetUrlResolutionError, OxyAuthenticationError, OxyAuthenticationTimeo
|
|
|
61
61
|
|
|
62
62
|
// Import mixin composition helper
|
|
63
63
|
import { composeOxyServices } from './mixins';
|
|
64
|
+
import type { OxyAuthRefusal } from './mixins/OxyServices.utility';
|
|
64
65
|
|
|
65
66
|
/**
|
|
66
67
|
* OxyServices - Unified client library for interacting with the Oxy API
|
|
@@ -120,11 +121,18 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
120
121
|
createLinkedClient(config: OxyConfig): LinkedHttpClient;
|
|
121
122
|
|
|
122
123
|
// Express.js middleware
|
|
124
|
+
//
|
|
125
|
+
// This list is the one CONSUMERS see: `OxyAuthMiddlewareOptions` in
|
|
126
|
+
// `server/auth.ts` is `Parameters<OxyServices['auth']>[0]`, so an option that
|
|
127
|
+
// exists on the mixin and not here does not exist for anybody outside this
|
|
128
|
+
// package. `onRefusal` shipped in 1.5.0 exactly that way — present in the
|
|
129
|
+
// implementation, invisible to every host that tried to pass it.
|
|
123
130
|
auth(options?: {
|
|
124
131
|
debug?: boolean;
|
|
125
132
|
onError?: (error: unknown) => unknown;
|
|
126
133
|
loadUser?: boolean;
|
|
127
134
|
optional?: boolean;
|
|
135
|
+
onRefusal?: (refusal: OxyAuthRefusal) => void;
|
|
128
136
|
serviceTokenJwksUrl?: string;
|
|
129
137
|
jwtSecret?: string;
|
|
130
138
|
expectedIssuer?: string;
|
|
@@ -139,6 +147,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
139
147
|
// Service-token-only middleware (delegates to auth() internally)
|
|
140
148
|
serviceAuth(options?: {
|
|
141
149
|
debug?: boolean;
|
|
150
|
+
onRefusal?: (refusal: OxyAuthRefusal) => void;
|
|
142
151
|
serviceTokenJwksUrl?: string;
|
|
143
152
|
jwtSecret?: string;
|
|
144
153
|
expectedIssuer?: string;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The post-429 refresh cooldown.
|
|
3
|
+
*
|
|
4
|
+
* `POST /session/device/token` is budgeted at 30 mints/minute
|
|
5
|
+
* (`packages/api/src/routes/sessionDevice.ts`). Once the access token is
|
|
6
|
+
* EXPIRED, the request-driven refresh lanes — the request-time preflight in
|
|
7
|
+
* `getAuthHeader` and the 401 retry — reattempt on
|
|
8
|
+
* `EXPIRED_TOKEN_REFRESH_COOLDOWN_MS`, i.e. one mint per second, 60/min. That
|
|
9
|
+
* is correct against an unreachable server (recover the instant it returns) and
|
|
10
|
+
* exactly wrong against a rationing one: the retries spend the very budget they
|
|
11
|
+
* are waiting on, so the limiter stays tripped for as long as the app keeps
|
|
12
|
+
* issuing requests and the session can never heal itself.
|
|
13
|
+
*
|
|
14
|
+
* These tests pin the discrimination: a 429 lengthens the NEXT cooldown to the
|
|
15
|
+
* limiter's own window, every other failure keeps the token-state cooldowns, and
|
|
16
|
+
* a success clears it.
|
|
17
|
+
*
|
|
18
|
+
* `Date.now` is stubbed rather than using fake timers: the cooldown is a pure
|
|
19
|
+
* comparison against a stored epoch, so moving the clock is the whole
|
|
20
|
+
* experiment, and real microtasks keep the refresh promises resolving normally.
|
|
21
|
+
*/
|
|
22
|
+
import { HttpService } from '../HttpService';
|
|
23
|
+
|
|
24
|
+
function createJwt(payload: Record<string, unknown>): string {
|
|
25
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
26
|
+
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.signature`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The server's mint budget window — the interval the fix must wait out. */
|
|
30
|
+
const LIMITER_WINDOW_MS = 60_000;
|
|
31
|
+
|
|
32
|
+
/** Comfortably past the expired-token cooldown (1s) but inside the 429 window. */
|
|
33
|
+
const BETWEEN_COOLDOWNS_MS = 30_000;
|
|
34
|
+
|
|
35
|
+
describe('HttpService — refresh cooldown after a rate-limited mint', () => {
|
|
36
|
+
let clock = 1_700_000_000_000;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
clock = 1_700_000_000_000;
|
|
40
|
+
jest.spyOn(Date, 'now').mockImplementation(() => clock);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
jest.restoreAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** A client whose CURRENT access token is already past `exp`. */
|
|
48
|
+
function expiredTokenClient(): HttpService {
|
|
49
|
+
const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
|
|
50
|
+
http.setTokens(createJwt({ userId: 'u', exp: Math.floor(clock / 1000) - 60 }));
|
|
51
|
+
return http;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it('retries an expired token ~1s after an ORDINARY failure (the unreachable-server lane is untouched)', async () => {
|
|
55
|
+
const http = expiredTokenClient();
|
|
56
|
+
const handler = jest.fn(async () => null);
|
|
57
|
+
http.setAuthRefreshHandler(handler);
|
|
58
|
+
|
|
59
|
+
expect(await http.refreshAccessToken('preflight')).toBeNull();
|
|
60
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
61
|
+
|
|
62
|
+
clock += 1_001;
|
|
63
|
+
expect(await http.refreshAccessToken('preflight')).toBeNull();
|
|
64
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('suppresses the retry for the limiter window after a 429, instead of hammering once a second', async () => {
|
|
68
|
+
const http = expiredTokenClient();
|
|
69
|
+
const handler = jest.fn(async () => {
|
|
70
|
+
// What `refreshDeviceSecretArm` does when the mint answers 429.
|
|
71
|
+
http.noteRefreshRateLimited();
|
|
72
|
+
return null;
|
|
73
|
+
});
|
|
74
|
+
http.setAuthRefreshHandler(handler);
|
|
75
|
+
|
|
76
|
+
expect(await http.refreshAccessToken('preflight')).toBeNull();
|
|
77
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
78
|
+
|
|
79
|
+
// Past the expired-token cooldown. Without the 429 discrimination this is
|
|
80
|
+
// where the client resumes one mint per second and holds the limiter down.
|
|
81
|
+
clock += BETWEEN_COOLDOWNS_MS;
|
|
82
|
+
expect(await http.refreshAccessToken('preflight')).toBeNull();
|
|
83
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
84
|
+
|
|
85
|
+
// Once the limiter's window has drained, exactly one probe goes out.
|
|
86
|
+
clock += LIMITER_WINDOW_MS;
|
|
87
|
+
expect(await http.refreshAccessToken('preflight')).toBeNull();
|
|
88
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('holds a rate-limited client under the mint budget while requests keep arriving', async () => {
|
|
92
|
+
const http = expiredTokenClient();
|
|
93
|
+
const handler = jest.fn(async () => {
|
|
94
|
+
http.noteRefreshRateLimited();
|
|
95
|
+
return null;
|
|
96
|
+
});
|
|
97
|
+
http.setAuthRefreshHandler(handler);
|
|
98
|
+
|
|
99
|
+
// One simulated minute of a busy app: a request every 250ms, each one
|
|
100
|
+
// driving the preflight/401 refresh lane.
|
|
101
|
+
for (let elapsed = 0; elapsed < 60_000; elapsed += 250) {
|
|
102
|
+
await http.refreshAccessToken('preflight');
|
|
103
|
+
clock += 250;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The server's budget is 30/min. The unfixed client issues 60.
|
|
107
|
+
expect(handler.mock.calls.length).toBeLessThanOrEqual(2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('clears the lengthened cooldown once a refresh succeeds', async () => {
|
|
111
|
+
const http = expiredTokenClient();
|
|
112
|
+
const fresh = createJwt({ userId: 'u', exp: Math.floor(clock / 1000) + 900 });
|
|
113
|
+
const handler = jest
|
|
114
|
+
.fn<Promise<string | null>, []>()
|
|
115
|
+
.mockImplementationOnce(async () => {
|
|
116
|
+
http.noteRefreshRateLimited();
|
|
117
|
+
return null;
|
|
118
|
+
})
|
|
119
|
+
.mockImplementationOnce(async () => fresh)
|
|
120
|
+
// A later ordinary failure must be back on the short cooldown, proving the
|
|
121
|
+
// 429 state did not become sticky.
|
|
122
|
+
.mockImplementation(async () => null);
|
|
123
|
+
http.setAuthRefreshHandler(handler);
|
|
124
|
+
|
|
125
|
+
await http.refreshAccessToken('preflight');
|
|
126
|
+
clock += LIMITER_WINDOW_MS + 1;
|
|
127
|
+
expect(await http.refreshAccessToken('preflight')).toBe(fresh);
|
|
128
|
+
|
|
129
|
+
// The planted token is fresh, so this is the still-valid (15s) cooldown.
|
|
130
|
+
clock += 16_000;
|
|
131
|
+
await http.refreshAccessToken('preflight');
|
|
132
|
+
expect(handler).toHaveBeenCalledTimes(3);
|
|
133
|
+
|
|
134
|
+
clock += 16_000;
|
|
135
|
+
await http.refreshAccessToken('preflight');
|
|
136
|
+
expect(handler).toHaveBeenCalledTimes(4);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('returns to the short cooldown when a later failure is NOT a 429', async () => {
|
|
140
|
+
const http = expiredTokenClient();
|
|
141
|
+
const handler = jest
|
|
142
|
+
.fn<Promise<string | null>, []>()
|
|
143
|
+
.mockImplementationOnce(async () => {
|
|
144
|
+
http.noteRefreshRateLimited();
|
|
145
|
+
return null;
|
|
146
|
+
})
|
|
147
|
+
// The limiter window passed and the endpoint is merely erroring now.
|
|
148
|
+
.mockImplementation(async () => null);
|
|
149
|
+
http.setAuthRefreshHandler(handler);
|
|
150
|
+
|
|
151
|
+
await http.refreshAccessToken('preflight');
|
|
152
|
+
clock += LIMITER_WINDOW_MS + 1;
|
|
153
|
+
await http.refreshAccessToken('preflight');
|
|
154
|
+
expect(handler).toHaveBeenCalledTimes(2);
|
|
155
|
+
|
|
156
|
+
clock += 1_001;
|
|
157
|
+
await http.refreshAccessToken('preflight');
|
|
158
|
+
expect(handler).toHaveBeenCalledTimes(3);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The middleware options a CONSUMER can pass must be the ones the middleware
|
|
3
|
+
* actually takes.
|
|
4
|
+
*
|
|
5
|
+
* `src/OxyServices.ts` re-declares `auth()` and `serviceAuth()` by hand, with
|
|
6
|
+
* their option objects written out inline, because declaration emit for the
|
|
7
|
+
* mixin pipeline does not carry them. That hand-written interface is what
|
|
8
|
+
* downstream packages see — `OxyAuthMiddlewareOptions` in `server/auth.ts` is
|
|
9
|
+
* literally `Parameters<OxyServices['auth']>[0]` — so an option that exists on
|
|
10
|
+
* the mixin and not there exists for this package and for nobody else.
|
|
11
|
+
*
|
|
12
|
+
* `onRefusal` shipped in 1.5.0 exactly that way: implemented, tested, exported,
|
|
13
|
+
* and rejected by the first host that tried to pass it
|
|
14
|
+
* (`TS2345 … has no properties in common with`). Nothing in this package could
|
|
15
|
+
* see it, because everything in this package resolves the mixin's own
|
|
16
|
+
* declaration, where the option is present. Only the published `.d.ts` loses it.
|
|
17
|
+
*
|
|
18
|
+
* So the check is on the SOURCE TEXT of the two declarations, which is the
|
|
19
|
+
* thing that diverges. It is not a type test: a type test here reads the merged
|
|
20
|
+
* interface and passes either way — measured, by deleting the option and
|
|
21
|
+
* watching `tsc --noEmit` stay green.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
|
|
27
|
+
const SRC = join(__dirname, '..');
|
|
28
|
+
|
|
29
|
+
/** Every `name?:` key declared directly inside one brace-balanced block. */
|
|
30
|
+
function optionKeys(source: string, startPattern: RegExp): string[] {
|
|
31
|
+
const match = startPattern.exec(source);
|
|
32
|
+
if (!match) throw new Error(`declaration not found: ${String(startPattern)}`);
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let index = source.indexOf('{', match.index);
|
|
35
|
+
const open = index;
|
|
36
|
+
do {
|
|
37
|
+
const char = source[index];
|
|
38
|
+
if (char === '{') depth += 1;
|
|
39
|
+
else if (char === '}') depth -= 1;
|
|
40
|
+
index += 1;
|
|
41
|
+
} while (depth > 0 && index < source.length);
|
|
42
|
+
const body = source.slice(open + 1, index - 1);
|
|
43
|
+
// Only keys at THIS level: a nested object literal would otherwise contribute
|
|
44
|
+
// its own keys. None exist today; the guard keeps that from becoming silent.
|
|
45
|
+
const withoutNested = body.replace(/\{[^{}]*\}/g, '');
|
|
46
|
+
return [...withoutNested.matchAll(/^\s*(\w+)\??\s*:/gm)].map((m) => m[1] as string).sort();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('the public OxyServices interface declares the options the mixin takes', () => {
|
|
50
|
+
const mixin = readFileSync(join(SRC, 'mixins/OxyServices.utility.ts'), 'utf8');
|
|
51
|
+
const publicInterface = readFileSync(join(SRC, 'OxyServices.ts'), 'utf8');
|
|
52
|
+
|
|
53
|
+
it('auth() — same option names on both declarations', () => {
|
|
54
|
+
const declared = optionKeys(mixin, /interface AuthMiddlewareOptions/);
|
|
55
|
+
const published = optionKeys(publicInterface, /^ {2}auth\(options\?: \{/m);
|
|
56
|
+
|
|
57
|
+
expect(declared).toContain('onRefusal');
|
|
58
|
+
expect(published).toEqual(declared);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('serviceAuth() — same option names on both declarations', () => {
|
|
62
|
+
const declared = optionKeys(mixin, /^ {4}serviceAuth\(options: \{/m);
|
|
63
|
+
const published = optionKeys(publicInterface, /^ {2}serviceAuth\(options\?: \{/m);
|
|
64
|
+
|
|
65
|
+
expect(declared).toContain('onRefusal');
|
|
66
|
+
expect(published).toEqual(declared);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('reads real declarations rather than matching nothing', () => {
|
|
70
|
+
// The floor. A regex that stopped matching would make both cases above
|
|
71
|
+
// compare two empty lists and pass.
|
|
72
|
+
expect(optionKeys(mixin, /interface AuthMiddlewareOptions/).length).toBeGreaterThan(5);
|
|
73
|
+
expect(optionKeys(publicInterface, /^ {2}auth\(options\?: \{/m).length).toBeGreaterThan(5);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it, jest } from '@jest/globals';
|
|
2
|
+
|
|
3
|
+
import { OxyServices } from '../OxyServices';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How an official service stops holding a secret WITHOUT a code change.
|
|
7
|
+
*
|
|
8
|
+
* `getServiceToken()` has always required an api key and secret. It now falls
|
|
9
|
+
* back to workload identity (ADR 0026) when there is none — which means the
|
|
10
|
+
* migration for each of Oxy's own services is "delete two environment
|
|
11
|
+
* variables", not "edit and redeploy an app".
|
|
12
|
+
*
|
|
13
|
+
* Three things have to be true for that to be safe, and each is asserted below:
|
|
14
|
+
*
|
|
15
|
+
* 1. A configured credential still wins. The fallback must not quietly change
|
|
16
|
+
* how an already-working service authenticates.
|
|
17
|
+
* 2. With no credential AND no way to attest — a laptop, a CI box — the caller
|
|
18
|
+
* gets the same "no credentials" error as before, not a confusing failure
|
|
19
|
+
* from a container credentials endpoint that is not there.
|
|
20
|
+
* 3. The token is cached, so a service does not mint one per request.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
jest.mock('../server/workloadIdentity', () => ({
|
|
24
|
+
canAttestWorkloadIdentity: () => Boolean(process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI),
|
|
25
|
+
requestWorkloadServiceToken: jest.fn(async () => ({
|
|
26
|
+
token: 'token-from-attestation',
|
|
27
|
+
expiresIn: 3600,
|
|
28
|
+
appName: 'Mention',
|
|
29
|
+
})),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import { requestWorkloadServiceToken } from '../server/workloadIdentity';
|
|
33
|
+
|
|
34
|
+
const attestations = requestWorkloadServiceToken as unknown as jest.Mock;
|
|
35
|
+
|
|
36
|
+
function client() {
|
|
37
|
+
return new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('service token — workload fallback', () => {
|
|
41
|
+
const previous = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
attestations.mockClear();
|
|
45
|
+
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '/v2/credentials/abc';
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(() => {
|
|
49
|
+
// Cleared by assignment rather than `delete`: Biome refuses `delete` on a
|
|
50
|
+
// property access, and an empty value reads as "cannot attest" everywhere
|
|
51
|
+
// that matters here.
|
|
52
|
+
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = previous ?? '';
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('attests when no credential is configured', async () => {
|
|
56
|
+
await expect(client().getServiceToken()).resolves.toBe('token-from-attestation');
|
|
57
|
+
expect(attestations).toHaveBeenCalledWith({ baseUrl: 'https://api.oxy.so' });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('mints once and serves the cached token afterwards', async () => {
|
|
61
|
+
const oxy = client();
|
|
62
|
+
|
|
63
|
+
await oxy.getServiceToken();
|
|
64
|
+
await oxy.getServiceToken();
|
|
65
|
+
|
|
66
|
+
expect(attestations).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('leaves a configured credential alone', async () => {
|
|
70
|
+
const oxy = client();
|
|
71
|
+
oxy.configureServiceAuth('oxy_dk_example', 'secret-example');
|
|
72
|
+
// The credential path goes to the network; failing there is fine — what
|
|
73
|
+
// matters is that it did not silently attest instead.
|
|
74
|
+
await oxy.getServiceToken().catch(() => undefined);
|
|
75
|
+
|
|
76
|
+
expect(attestations).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('still says "no credentials" where nothing can be attested', async () => {
|
|
80
|
+
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI = '';
|
|
81
|
+
|
|
82
|
+
await expect(client().getServiceToken()).rejects.toThrow(/Service credentials not provided/);
|
|
83
|
+
expect(attestations).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
});
|