@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
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxy.so/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"@oxy.so/telemetry": "^1.1.1",
|
|
119
119
|
"@noble/ciphers": "^1.3.0",
|
|
120
120
|
"@noble/hashes": "^1.8.0",
|
|
121
|
-
"@oxy.so/contracts": "^1.
|
|
121
|
+
"@oxy.so/contracts": "^1.3.0",
|
|
122
122
|
"@oxy.so/protocol": "^1.0.1",
|
|
123
123
|
"@scure/bip39": "^1.6.0",
|
|
124
124
|
"buffer": "^6.0.3",
|
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}):
|
|
@@ -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,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
|
+
});
|
|
@@ -1,96 +1,138 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* receives exactly the identity
|
|
4
|
-
*
|
|
2
|
+
* Giving a root to Commons (ADR 0024 D6): both sides derive the same key and code;
|
|
3
|
+
* the responder receives exactly the declared identity; the initiator's key is
|
|
4
|
+
* committed before the responder's is chosen, so a relay cannot steer both codes
|
|
5
|
+
* together; and the receipt binds the move, root, both keys and the relayed
|
|
6
|
+
* ciphertext.
|
|
5
7
|
*/
|
|
6
8
|
|
|
9
|
+
import { generateSecp256k1KeyPair } from '@oxy.so/protocol/secp256k1';
|
|
10
|
+
import { signMessage } from '@oxy.so/protocol';
|
|
7
11
|
import {
|
|
8
12
|
buildMoveQrPayload,
|
|
13
|
+
createMoveCommitment,
|
|
9
14
|
deriveMoveKey,
|
|
10
15
|
deriveMoveSas,
|
|
16
|
+
digestMoveCiphertext,
|
|
11
17
|
generateMoveEphemeralKeyPair,
|
|
12
|
-
IDENTITY_MOVE_ACTIONS,
|
|
13
18
|
openMovedIdentity,
|
|
14
19
|
parseMoveQrPayload,
|
|
15
20
|
sealIdentityForMove,
|
|
16
|
-
|
|
21
|
+
signMoveReceipt,
|
|
22
|
+
verifyMoveCommitment,
|
|
17
23
|
verifyMoveReceipt,
|
|
18
24
|
} from '../identityMove';
|
|
19
|
-
import { generateWebIdentity } from '../webIdentityCarrier';
|
|
25
|
+
import { deriveIdentityFromMnemonic, generateWebIdentity } from '../webIdentityCarrier';
|
|
20
26
|
|
|
21
27
|
const MOVE_ID = '0123456789abcdef0123456789abcdef';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
28
|
+
const MNEMONIC_24 =
|
|
29
|
+
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art';
|
|
30
|
+
|
|
31
|
+
function code(initiator: string, responder: string, commitment: string, moveId = MOVE_ID): string {
|
|
32
|
+
return deriveMoveSas({ moveId, initiatorEphemeralPublicKey: initiator, responderEphemeralPublicKey: responder, initiatorCommitment: commitment });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('a move between two honest sides', () => {
|
|
36
|
+
it('delivers the declared identity — 12 or 24 words — and both screens show the same code', () => {
|
|
37
|
+
for (const identity of [generateWebIdentity(), deriveIdentityFromMnemonic(MNEMONIC_24)]) {
|
|
38
|
+
const web = generateMoveEphemeralKeyPair();
|
|
39
|
+
const commons = generateMoveEphemeralKeyPair();
|
|
40
|
+
const { commitment, nonce } = createMoveCommitment(web.publicKey);
|
|
41
|
+
expect(verifyMoveCommitment(web.publicKey, nonce, commitment)).toBe(true);
|
|
42
|
+
|
|
43
|
+
const webKey = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
|
|
44
|
+
const commonsKey = deriveMoveKey(commons.privateKey, web.publicKey, MOVE_ID);
|
|
45
|
+
expect(Buffer.from(webKey).equals(Buffer.from(commonsKey))).toBe(true);
|
|
46
|
+
expect(code(web.publicKey, commons.publicKey, commitment)).toMatch(/^\d{6}$/);
|
|
47
|
+
expect(code(web.publicKey, commons.publicKey, commitment, MOVE_ID.toUpperCase())).toBe(code(web.publicKey, commons.publicKey, commitment));
|
|
48
|
+
|
|
49
|
+
const sealed = sealIdentityForMove(identity, webKey, MOVE_ID);
|
|
50
|
+
expect(JSON.stringify(sealed)).not.toContain(identity.privateKey);
|
|
51
|
+
expect(openMovedIdentity(sealed, commonsKey, MOVE_ID, identity.publicKey)).toEqual(identity);
|
|
52
|
+
}
|
|
40
53
|
});
|
|
41
54
|
});
|
|
42
55
|
|
|
43
|
-
describe('a relay that substitutes
|
|
56
|
+
describe('a relay that substitutes keys', () => {
|
|
57
|
+
it('cannot reveal a key the commitment does not open', () => {
|
|
58
|
+
const web = generateSecp256k1KeyPair();
|
|
59
|
+
const { commitment, nonce } = createMoveCommitment(web.publicKey);
|
|
60
|
+
expect(verifyMoveCommitment(generateSecp256k1KeyPair().publicKey, nonce, commitment)).toBe(false);
|
|
61
|
+
expect(verifyMoveCommitment(web.publicKey, 'ab'.repeat(32), commitment)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
44
64
|
it('shows a different code on each screen and cannot open the identity', () => {
|
|
45
65
|
const identity = generateWebIdentity();
|
|
46
66
|
const web = generateMoveEphemeralKeyPair();
|
|
47
67
|
const commons = generateMoveEphemeralKeyPair();
|
|
48
68
|
const attacker = generateMoveEphemeralKeyPair();
|
|
69
|
+
const { commitment } = createMoveCommitment(web.publicKey);
|
|
70
|
+
const attackerCommitment = createMoveCommitment(attacker.publicKey).commitment;
|
|
49
71
|
|
|
50
|
-
|
|
51
|
-
const webSas = deriveMoveSas(MOVE_ID, web.publicKey, attacker.publicKey);
|
|
52
|
-
const commonsSas = deriveMoveSas(MOVE_ID, attacker.publicKey, commons.publicKey);
|
|
53
|
-
expect(webSas).not.toBe(commonsSas);
|
|
72
|
+
expect(code(web.publicKey, attacker.publicKey, commitment)).not.toBe(code(attacker.publicKey, commons.publicKey, attackerCommitment));
|
|
54
73
|
|
|
55
|
-
// Even sealed to the attacker, Commons' own key cannot open it.
|
|
56
74
|
const sealedToAttacker = sealIdentityForMove(identity, deriveMoveKey(web.privateKey, attacker.publicKey, MOVE_ID), MOVE_ID);
|
|
57
75
|
expect(() =>
|
|
58
76
|
openMovedIdentity(sealedToAttacker, deriveMoveKey(commons.privateKey, web.publicKey, MOVE_ID), MOVE_ID, identity.publicKey),
|
|
59
77
|
).toThrow('could not be opened');
|
|
60
78
|
});
|
|
61
79
|
|
|
62
|
-
it('
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
expect(() => openMovedIdentity(sealedOther, key, MOVE_ID, declared.publicKey)).toThrow();
|
|
80
|
+
it('changes the code when any input changes, including the commitment', () => {
|
|
81
|
+
const web = generateSecp256k1KeyPair();
|
|
82
|
+
const commons = generateSecp256k1KeyPair();
|
|
83
|
+
const { commitment } = createMoveCommitment(web.publicKey);
|
|
84
|
+
const base = code(web.publicKey, commons.publicKey, commitment);
|
|
85
|
+
expect(code(web.publicKey, commons.publicKey, createMoveCommitment(web.publicKey).commitment)).not.toBe(base);
|
|
86
|
+
expect(code(web.publicKey, generateSecp256k1KeyPair().publicKey, commitment)).not.toBe(base);
|
|
87
|
+
expect(code(commons.publicKey, web.publicKey, commitment)).not.toBe(base);
|
|
71
88
|
});
|
|
72
89
|
|
|
73
|
-
it('
|
|
74
|
-
const
|
|
90
|
+
it('refuses a sealed identity that is not the one the move declared, or another move’s ciphertext', () => {
|
|
91
|
+
const declared = generateWebIdentity();
|
|
75
92
|
const web = generateMoveEphemeralKeyPair();
|
|
76
93
|
const commons = generateMoveEphemeralKeyPair();
|
|
77
94
|
const key = deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID);
|
|
78
|
-
|
|
79
|
-
expect(() => openMovedIdentity(
|
|
95
|
+
expect(() => openMovedIdentity(sealIdentityForMove(generateWebIdentity(), key, MOVE_ID), key, MOVE_ID, declared.publicKey)).toThrow();
|
|
96
|
+
expect(() => openMovedIdentity(sealIdentityForMove(declared, key, MOVE_ID), key, 'ffffffffffffffffffffffffffffffff', declared.publicKey)).toThrow();
|
|
80
97
|
});
|
|
81
98
|
});
|
|
82
99
|
|
|
83
100
|
describe('receipts', () => {
|
|
84
|
-
|
|
101
|
+
function transfer() {
|
|
85
102
|
const identity = generateWebIdentity();
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
103
|
+
const web = generateMoveEphemeralKeyPair();
|
|
104
|
+
const commons = generateMoveEphemeralKeyPair();
|
|
105
|
+
const sealed = sealIdentityForMove(identity, deriveMoveKey(web.privateKey, commons.publicKey, MOVE_ID), MOVE_ID);
|
|
106
|
+
const claims = {
|
|
107
|
+
moveId: MOVE_ID,
|
|
108
|
+
rootPublicKey: identity.publicKey,
|
|
109
|
+
initiatorEphemeralPublicKey: web.publicKey,
|
|
110
|
+
responderEphemeralPublicKey: commons.publicKey,
|
|
111
|
+
ciphertextDigest: digestMoveCiphertext(sealed),
|
|
112
|
+
};
|
|
113
|
+
return { identity, sealed, claims };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
it('verify for exactly the move, root, keys and ciphertext they were made over', async () => {
|
|
117
|
+
const { identity, sealed, claims } = transfer();
|
|
118
|
+
const receipt = await signMoveReceipt((message) => signMessage(message, identity.privateKey), claims);
|
|
119
|
+
expect(await verifyMoveReceipt(claims, receipt.signature)).toBe(true);
|
|
120
|
+
|
|
121
|
+
const other = { ...sealed, ciphertext: `${sealed.ciphertext.slice(0, -2)}00` };
|
|
122
|
+
for (const changed of [
|
|
123
|
+
{ ...claims, moveId: 'ffffffffffffffffffffffffffffffff' },
|
|
124
|
+
{ ...claims, responderEphemeralPublicKey: generateSecp256k1KeyPair().publicKey },
|
|
125
|
+
{ ...claims, initiatorEphemeralPublicKey: generateSecp256k1KeyPair().publicKey },
|
|
126
|
+
{ ...claims, ciphertextDigest: digestMoveCiphertext(other) },
|
|
127
|
+
]) {
|
|
128
|
+
expect(await verifyMoveReceipt(changed, receipt.signature)).toBe(false);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
91
131
|
|
|
92
|
-
|
|
93
|
-
|
|
132
|
+
it('are not the root’s if another key signed them', async () => {
|
|
133
|
+
const { claims } = transfer();
|
|
134
|
+
const forged = await signMoveReceipt((message) => signMessage(message, generateWebIdentity().privateKey), claims);
|
|
135
|
+
expect(await verifyMoveReceipt(claims, forged.signature)).toBe(false);
|
|
94
136
|
});
|
|
95
137
|
});
|
|
96
138
|
|