@oxy.so/core 1.18.0 → 1.18.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 +30 -1
- package/dist/cjs/mixins/OxyServices.auth.js +56 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +30 -1
- package/dist/esm/mixins/OxyServices.auth.js +55 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +21 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +33 -0
- package/dist/types/models/interfaces.d.ts +21 -0
- package/package.json +1 -1
- package/src/HttpService.ts +35 -1
- package/src/__tests__/serviceIdentityWhenAnonymous.test.ts +159 -0
- package/src/mixins/OxyServices.auth.ts +55 -0
- package/src/models/interfaces.ts +21 -0
|
@@ -17,6 +17,11 @@ import type { DeviceSecretMintOutcome } from './session/refresh';
|
|
|
17
17
|
export type AuthRefreshReason = 'preflight' | 'response-401';
|
|
18
18
|
export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
|
|
19
19
|
export type AccessTokenProvider = () => string | null;
|
|
20
|
+
/**
|
|
21
|
+
* Supplies a bearer for a request made while this client holds NO user session
|
|
22
|
+
* — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}.
|
|
23
|
+
*/
|
|
24
|
+
export type AnonymousAuthProvider = () => Promise<string | null>;
|
|
20
25
|
/**
|
|
21
26
|
* A low-level authenticated request whose response body remains unread.
|
|
22
27
|
*
|
|
@@ -162,6 +167,7 @@ export declare class HttpService {
|
|
|
162
167
|
private noFresherTokenThan;
|
|
163
168
|
private authRefreshHandler;
|
|
164
169
|
private accessTokenProvider;
|
|
170
|
+
private anonymousAuthProvider;
|
|
165
171
|
private deviceSecretMintInFlight;
|
|
166
172
|
/**
|
|
167
173
|
* Bumped by every {@link endSession}. A re-mint captures it before its first
|
|
@@ -324,6 +330,13 @@ export declare class HttpService {
|
|
|
324
330
|
* Get auth header with automatic token refresh
|
|
325
331
|
*/
|
|
326
332
|
private getAuthHeader;
|
|
333
|
+
/**
|
|
334
|
+
* The bearer for a request that has no user session behind it, from the
|
|
335
|
+
* {@link AnonymousAuthProvider} if one is installed. Never throws: a provider
|
|
336
|
+
* that fails sends the request anonymous, exactly as it went before any
|
|
337
|
+
* provider existed.
|
|
338
|
+
*/
|
|
339
|
+
private getAnonymousAuthHeader;
|
|
327
340
|
refreshAccessToken(reason: AuthRefreshReason): Promise<string | null>;
|
|
328
341
|
/**
|
|
329
342
|
* Report that the refresh/mint endpoint answered 429 Too Many Requests, so the
|
|
@@ -442,6 +455,14 @@ export declare class HttpService {
|
|
|
442
455
|
hasSessionEnded(): boolean;
|
|
443
456
|
setAuthRefreshHandler(handler: AuthRefreshHandler | null): void;
|
|
444
457
|
setAccessTokenProvider(provider: AccessTokenProvider | null): void;
|
|
458
|
+
/**
|
|
459
|
+
* Authenticate requests that carry no user session with the bearer this
|
|
460
|
+
* provider returns — in practice a backend's own service token (see
|
|
461
|
+
* `OxyConfig.serviceIdentity`). A user session always wins: the provider is
|
|
462
|
+
* consulted only when there is no access token, and never for `skipAuth`
|
|
463
|
+
* requests. `null` removes it.
|
|
464
|
+
*/
|
|
465
|
+
setAnonymousAuthProvider(provider: AnonymousAuthProvider | null): void;
|
|
445
466
|
clearTokens(): void;
|
|
446
467
|
/**
|
|
447
468
|
* Subscribe to access-token changes on this instance.
|
|
@@ -322,6 +322,11 @@ interface ServiceTokenCacheEntry {
|
|
|
322
322
|
export declare class ServiceCredentialMismatchError extends Error {
|
|
323
323
|
constructor();
|
|
324
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* How long a failed service-token mint sends session-less requests anonymous
|
|
327
|
+
* before the next attempt, under `serviceIdentity: 'when-anonymous'`.
|
|
328
|
+
*/
|
|
329
|
+
export declare const ANONYMOUS_SERVICE_TOKEN_RETRY_MS = 30000;
|
|
325
330
|
export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
326
331
|
new (...args: any[]): {
|
|
327
332
|
/**
|
|
@@ -346,6 +351,34 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
|
|
|
346
351
|
_serviceApiKey: string | null;
|
|
347
352
|
/** @internal Raw apiSecret stored by configureServiceAuth() for use by getServiceToken() */
|
|
348
353
|
_serviceApiSecret: string | null;
|
|
354
|
+
/**
|
|
355
|
+
* Before this instant, a request without a user session does not try to
|
|
356
|
+
* mint a service token and goes out anonymous. Set after a failed mint; see
|
|
357
|
+
* {@link _serviceTokenForAnonymousRequest}.
|
|
358
|
+
*
|
|
359
|
+
* @internal
|
|
360
|
+
*/
|
|
361
|
+
_anonymousServiceTokenRetryAt: number;
|
|
362
|
+
/**
|
|
363
|
+
* The bearer for a request with no user session, under `serviceIdentity:
|
|
364
|
+
* 'when-anonymous'`: this process's service token, or `null` to send the
|
|
365
|
+
* request anonymous as before.
|
|
366
|
+
*
|
|
367
|
+
* `null`, never a throw, whenever there is no token to offer:
|
|
368
|
+
* - no key pair and no attestable workload (a local checkout) — asking
|
|
369
|
+
* costs nothing and there is nothing to ask for;
|
|
370
|
+
* - the mint failed. The request still goes out, anonymous, and so does
|
|
371
|
+
* every request for the next {@link ANONYMOUS_SERVICE_TOKEN_RETRY_MS}, so
|
|
372
|
+
* an Oxy that is refusing attestation (seen for ~2.5 minutes after a
|
|
373
|
+
* deploy on 2026-09-25) is asked once per window rather than once per
|
|
374
|
+
* read, and no read waits on a mint that just failed.
|
|
375
|
+
*
|
|
376
|
+
* The token itself comes from {@link getServiceToken}: cached for its
|
|
377
|
+
* lifetime and single-flight, so concurrent reads share one mint.
|
|
378
|
+
*
|
|
379
|
+
* @internal
|
|
380
|
+
*/
|
|
381
|
+
_serviceTokenForAnonymousRequest(): Promise<string | null>;
|
|
349
382
|
/**
|
|
350
383
|
* Hash an apiKey into a stable Map cache key. Uses Node's SHA-256 — service
|
|
351
384
|
* tokens are only ever issued by a Node host (the SDK on web/RN never has
|
|
@@ -13,6 +13,27 @@ export interface OxyConfig {
|
|
|
13
13
|
* bounce (which uses the RP origin, not this registered client id).
|
|
14
14
|
*/
|
|
15
15
|
clientId?: string;
|
|
16
|
+
/**
|
|
17
|
+
* How a BACKEND identifies itself on requests that carry no user session.
|
|
18
|
+
*
|
|
19
|
+
* - `'never'` (default): such requests go out anonymous. Right for browsers
|
|
20
|
+
* and apps, which have no service identity to offer.
|
|
21
|
+
* - `'when-anonymous'`: they carry this process's service token instead —
|
|
22
|
+
* from `configureServiceAuth()`'s key pair, or, with none, from workload
|
|
23
|
+
* attestation (ADR 0026). A user session, when there is one, still wins,
|
|
24
|
+
* and a process that can produce no service token (a local checkout) keeps
|
|
25
|
+
* sending the request anonymous.
|
|
26
|
+
*
|
|
27
|
+
* Why a server wants it: oxy-api charges anonymous traffic to the SOURCE
|
|
28
|
+
* ADDRESS (`rl:general`, and a +500 ms `slowDown` per request past 100 in 15
|
|
29
|
+
* minutes), and a fleet leaves through one NAT address, so every anonymous
|
|
30
|
+
* read a backend makes shares — and past the threshold pays for — one budget.
|
|
31
|
+
* A first-party service token is exempt from both and charged to its own
|
|
32
|
+
* application's budget instead. Measured from Mention's task (2026-09-25):
|
|
33
|
+
* `GET /users/:id` 543–575 ms anonymous past the threshold, `POST
|
|
34
|
+
* /users/by-ids` 19–24 ms with the service token.
|
|
35
|
+
*/
|
|
36
|
+
serviceIdentity?: 'never' | 'when-anonymous';
|
|
16
37
|
/**
|
|
17
38
|
* Enable the per-instance GET response cache. Defaults to `true` (5-minute
|
|
18
39
|
* TTL). Set to `false` to disable caching entirely for this instance — GET
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -38,6 +38,11 @@ interface JwtPayload {
|
|
|
38
38
|
export type AuthRefreshReason = 'preflight' | 'response-401';
|
|
39
39
|
export type AuthRefreshHandler = (reason: AuthRefreshReason) => Promise<string | null>;
|
|
40
40
|
export type AccessTokenProvider = () => string | null;
|
|
41
|
+
/**
|
|
42
|
+
* Supplies a bearer for a request made while this client holds NO user session
|
|
43
|
+
* — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}.
|
|
44
|
+
*/
|
|
45
|
+
export type AnonymousAuthProvider = () => Promise<string | null>;
|
|
41
46
|
|
|
42
47
|
/**
|
|
43
48
|
* A low-level authenticated request whose response body remains unread.
|
|
@@ -378,6 +383,7 @@ export class HttpService {
|
|
|
378
383
|
private noFresherTokenThan: string | null = null;
|
|
379
384
|
private authRefreshHandler: AuthRefreshHandler | null = null;
|
|
380
385
|
private accessTokenProvider: AccessTokenProvider | null = null;
|
|
386
|
+
private anonymousAuthProvider: AnonymousAuthProvider | null = null;
|
|
381
387
|
private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
|
|
382
388
|
/**
|
|
383
389
|
* Bumped by every {@link endSession}. A re-mint captures it before its first
|
|
@@ -1191,7 +1197,7 @@ export class HttpService {
|
|
|
1191
1197
|
private async getAuthHeader(): Promise<string | null> {
|
|
1192
1198
|
const accessToken = this.syncAccessTokenFromProvider();
|
|
1193
1199
|
if (!accessToken) {
|
|
1194
|
-
return
|
|
1200
|
+
return this.getAnonymousAuthHeader();
|
|
1195
1201
|
}
|
|
1196
1202
|
|
|
1197
1203
|
try {
|
|
@@ -1222,6 +1228,23 @@ export class HttpService {
|
|
|
1222
1228
|
}
|
|
1223
1229
|
}
|
|
1224
1230
|
|
|
1231
|
+
/**
|
|
1232
|
+
* The bearer for a request that has no user session behind it, from the
|
|
1233
|
+
* {@link AnonymousAuthProvider} if one is installed. Never throws: a provider
|
|
1234
|
+
* that fails sends the request anonymous, exactly as it went before any
|
|
1235
|
+
* provider existed.
|
|
1236
|
+
*/
|
|
1237
|
+
private async getAnonymousAuthHeader(): Promise<string | null> {
|
|
1238
|
+
if (!this.anonymousAuthProvider) return null;
|
|
1239
|
+
try {
|
|
1240
|
+
const token = await this.anonymousAuthProvider();
|
|
1241
|
+
return token ? `Bearer ${token}` : null;
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
this.logger.debug('Anonymous auth provider failed; sending the request anonymous:', error);
|
|
1244
|
+
return null;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1225
1248
|
async refreshAccessToken(reason: AuthRefreshReason): Promise<string | null> {
|
|
1226
1249
|
if (!this.authRefreshHandler) {
|
|
1227
1250
|
return null;
|
|
@@ -1565,6 +1588,17 @@ export class HttpService {
|
|
|
1565
1588
|
this.accessTokenProvider = provider;
|
|
1566
1589
|
}
|
|
1567
1590
|
|
|
1591
|
+
/**
|
|
1592
|
+
* Authenticate requests that carry no user session with the bearer this
|
|
1593
|
+
* provider returns — in practice a backend's own service token (see
|
|
1594
|
+
* `OxyConfig.serviceIdentity`). A user session always wins: the provider is
|
|
1595
|
+
* consulted only when there is no access token, and never for `skipAuth`
|
|
1596
|
+
* requests. `null` removes it.
|
|
1597
|
+
*/
|
|
1598
|
+
setAnonymousAuthProvider(provider: AnonymousAuthProvider | null): void {
|
|
1599
|
+
this.anonymousAuthProvider = provider;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1568
1602
|
clearTokens(): void {
|
|
1569
1603
|
this.tokenStore.clearTokens();
|
|
1570
1604
|
// Drop the response cache on logout. The cache is identity-scoped, so a
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `serviceIdentity: 'when-anonymous'` — a backend's session-less reads carry
|
|
3
|
+
* its service token.
|
|
4
|
+
*
|
|
5
|
+
* oxy-api charges anonymous requests to their source address, and a backend
|
|
6
|
+
* fleet leaves through one NAT address, so past 100 anonymous requests in 15
|
|
7
|
+
* minutes EVERY anonymous read that backend makes pays a +500 ms `slowDown`.
|
|
8
|
+
* Mention measured 543–575 ms for `GET /users/:id` anonymous against ~20 ms
|
|
9
|
+
* with its service token (OxyHQ/Mention#1173). A first-party service token is
|
|
10
|
+
* exempt, so a backend should present one on every read that has no user.
|
|
11
|
+
*
|
|
12
|
+
* What is asserted is the `Authorization` header that reaches `fetch`, because
|
|
13
|
+
* that is the only thing oxy-api's limiter sees.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { OxyServices } from '../OxyServices';
|
|
17
|
+
import { ANONYMOUS_SERVICE_TOKEN_RETRY_MS } from '../mixins/OxyServices.auth';
|
|
18
|
+
|
|
19
|
+
interface FetchCall {
|
|
20
|
+
url: string;
|
|
21
|
+
authorization: string | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function jsonResponse(data: unknown): Response {
|
|
25
|
+
return new Response(JSON.stringify({ data }), {
|
|
26
|
+
status: 200,
|
|
27
|
+
headers: { 'content-type': 'application/json' },
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function authorizationOf(init: RequestInit | undefined): string | undefined {
|
|
32
|
+
const headers = init?.headers;
|
|
33
|
+
if (!headers) return undefined;
|
|
34
|
+
if (headers instanceof Headers) return headers.get('authorization') ?? undefined;
|
|
35
|
+
const record = headers as Record<string, string>;
|
|
36
|
+
return record.Authorization ?? record.authorization;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createJwt(payload: Record<string, unknown>): string {
|
|
40
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
41
|
+
return `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode(payload)}.signature`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("OxyServices serviceIdentity: 'when-anonymous'", () => {
|
|
45
|
+
const originalFetch = globalThis.fetch;
|
|
46
|
+
let calls: FetchCall[];
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
calls = [];
|
|
50
|
+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
51
|
+
calls.push({ url: String(input), authorization: authorizationOf(init) });
|
|
52
|
+
return jsonResponse({ id: 'u1', username: 'alice', name: { displayName: 'Alice' } });
|
|
53
|
+
}) as typeof fetch;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
globalThis.fetch = originalFetch;
|
|
58
|
+
jest.restoreAllMocks();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function backend(): OxyServices {
|
|
62
|
+
return new OxyServices({ baseURL: 'https://api.oxy.test', serviceIdentity: 'when-anonymous' });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
it('sends a session-less read with the service token', async () => {
|
|
66
|
+
const oxy = backend();
|
|
67
|
+
jest.spyOn(oxy, '_canUseWorkloadIdentity').mockResolvedValue(true);
|
|
68
|
+
const getServiceToken = jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('svc-token');
|
|
69
|
+
|
|
70
|
+
await oxy.getUserById('u1');
|
|
71
|
+
await oxy.getProfileByUsername('alice');
|
|
72
|
+
|
|
73
|
+
expect(calls.map((call) => call.authorization)).toEqual(['Bearer svc-token', 'Bearer svc-token']);
|
|
74
|
+
expect(getServiceToken).toHaveBeenCalledTimes(2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('uses a configured key pair without asking about attestation', async () => {
|
|
78
|
+
const oxy = backend();
|
|
79
|
+
oxy.configureServiceAuth('oxy_dk_test', 'secret');
|
|
80
|
+
const canAttest = jest.spyOn(oxy, '_canUseWorkloadIdentity');
|
|
81
|
+
jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('keypair-token');
|
|
82
|
+
|
|
83
|
+
await oxy.getUserById('u1');
|
|
84
|
+
|
|
85
|
+
expect(calls[0].authorization).toBe('Bearer keypair-token');
|
|
86
|
+
expect(canAttest).not.toHaveBeenCalled();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('lets a user session win over the service token', async () => {
|
|
90
|
+
const oxy = backend();
|
|
91
|
+
const getServiceToken = jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('svc-token');
|
|
92
|
+
const userToken = createJwt({ userId: 'viewer', exp: Math.floor(Date.now() / 1000) + 3600 });
|
|
93
|
+
oxy.setTokens(userToken);
|
|
94
|
+
|
|
95
|
+
await oxy.getUserById('u1');
|
|
96
|
+
|
|
97
|
+
expect(calls[0].authorization).toBe(`Bearer ${userToken}`);
|
|
98
|
+
expect(getServiceToken).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('stays anonymous where no service token can exist (a local checkout)', async () => {
|
|
102
|
+
const oxy = backend();
|
|
103
|
+
jest.spyOn(oxy, '_canUseWorkloadIdentity').mockResolvedValue(false);
|
|
104
|
+
const getServiceToken = jest.spyOn(oxy, 'getServiceToken');
|
|
105
|
+
|
|
106
|
+
await oxy.getUserById('u1');
|
|
107
|
+
|
|
108
|
+
expect(calls[0].authorization).toBeUndefined();
|
|
109
|
+
expect(getServiceToken).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('sends the read anonymous when the mint fails, and does not retry the mint for a window', async () => {
|
|
113
|
+
const oxy = backend();
|
|
114
|
+
jest.spyOn(oxy, '_canUseWorkloadIdentity').mockResolvedValue(true);
|
|
115
|
+
const getServiceToken = jest
|
|
116
|
+
.spyOn(oxy, 'getServiceToken')
|
|
117
|
+
.mockRejectedValue(new Error('Oxy refused the workload attestation (401)'));
|
|
118
|
+
const now = jest.spyOn(Date, 'now');
|
|
119
|
+
const start = 1_800_000_000_000;
|
|
120
|
+
now.mockReturnValue(start);
|
|
121
|
+
|
|
122
|
+
// Different ids: each is a real request, not a cache hit or a deduplicated one.
|
|
123
|
+
await expect(oxy.getUserById('u1')).resolves.toBeDefined();
|
|
124
|
+
await expect(oxy.getUserById('u2')).resolves.toBeDefined();
|
|
125
|
+
|
|
126
|
+
expect(calls.map((call) => call.authorization)).toEqual([undefined, undefined]);
|
|
127
|
+
expect(getServiceToken).toHaveBeenCalledTimes(1);
|
|
128
|
+
|
|
129
|
+
// After the window the mint is tried again.
|
|
130
|
+
getServiceToken.mockResolvedValue('svc-token');
|
|
131
|
+
now.mockReturnValue(start + ANONYMOUS_SERVICE_TOKEN_RETRY_MS + 1);
|
|
132
|
+
await oxy.getUserById('u3');
|
|
133
|
+
|
|
134
|
+
expect(calls[2].authorization).toBe('Bearer svc-token');
|
|
135
|
+
expect(getServiceToken).toHaveBeenCalledTimes(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('never attaches a bearer to a skipAuth request', async () => {
|
|
139
|
+
const oxy = backend();
|
|
140
|
+
jest.spyOn(oxy, '_canUseWorkloadIdentity').mockResolvedValue(true);
|
|
141
|
+
const getServiceToken = jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('svc-token');
|
|
142
|
+
|
|
143
|
+
await oxy.makeRequest('GET', '/auth/lookup/alice', undefined, { skipAuth: true, cache: false });
|
|
144
|
+
|
|
145
|
+
expect(calls[0].authorization).toBeUndefined();
|
|
146
|
+
expect(getServiceToken).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('is off by default: a client without the option stays anonymous', async () => {
|
|
150
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
|
|
151
|
+
jest.spyOn(oxy, '_canUseWorkloadIdentity').mockResolvedValue(true);
|
|
152
|
+
const getServiceToken = jest.spyOn(oxy, 'getServiceToken').mockResolvedValue('svc-token');
|
|
153
|
+
|
|
154
|
+
await oxy.getUserById('u1');
|
|
155
|
+
|
|
156
|
+
expect(calls[0].authorization).toBeUndefined();
|
|
157
|
+
expect(getServiceToken).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -471,6 +471,12 @@ export class ServiceCredentialMismatchError extends Error {
|
|
|
471
471
|
}
|
|
472
472
|
}
|
|
473
473
|
|
|
474
|
+
/**
|
|
475
|
+
* How long a failed service-token mint sends session-less requests anonymous
|
|
476
|
+
* before the next attempt, under `serviceIdentity: 'when-anonymous'`.
|
|
477
|
+
*/
|
|
478
|
+
export const ANONYMOUS_SERVICE_TOKEN_RETRY_MS = 30_000;
|
|
479
|
+
|
|
474
480
|
export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
475
481
|
return class extends Base {
|
|
476
482
|
/**
|
|
@@ -497,8 +503,57 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
497
503
|
/** @internal Raw apiSecret stored by configureServiceAuth() for use by getServiceToken() */
|
|
498
504
|
_serviceApiSecret: string | null = null;
|
|
499
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Before this instant, a request without a user session does not try to
|
|
508
|
+
* mint a service token and goes out anonymous. Set after a failed mint; see
|
|
509
|
+
* {@link _serviceTokenForAnonymousRequest}.
|
|
510
|
+
*
|
|
511
|
+
* @internal
|
|
512
|
+
*/
|
|
513
|
+
_anonymousServiceTokenRetryAt = 0;
|
|
514
|
+
|
|
500
515
|
constructor(...args: any[]) {
|
|
501
516
|
super(...(args as [any]));
|
|
517
|
+
if (this.config.serviceIdentity === 'when-anonymous') {
|
|
518
|
+
this.httpService.setAnonymousAuthProvider(() => this._serviceTokenForAnonymousRequest());
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The bearer for a request with no user session, under `serviceIdentity:
|
|
524
|
+
* 'when-anonymous'`: this process's service token, or `null` to send the
|
|
525
|
+
* request anonymous as before.
|
|
526
|
+
*
|
|
527
|
+
* `null`, never a throw, whenever there is no token to offer:
|
|
528
|
+
* - no key pair and no attestable workload (a local checkout) — asking
|
|
529
|
+
* costs nothing and there is nothing to ask for;
|
|
530
|
+
* - the mint failed. The request still goes out, anonymous, and so does
|
|
531
|
+
* every request for the next {@link ANONYMOUS_SERVICE_TOKEN_RETRY_MS}, so
|
|
532
|
+
* an Oxy that is refusing attestation (seen for ~2.5 minutes after a
|
|
533
|
+
* deploy on 2026-09-25) is asked once per window rather than once per
|
|
534
|
+
* read, and no read waits on a mint that just failed.
|
|
535
|
+
*
|
|
536
|
+
* The token itself comes from {@link getServiceToken}: cached for its
|
|
537
|
+
* lifetime and single-flight, so concurrent reads share one mint.
|
|
538
|
+
*
|
|
539
|
+
* @internal
|
|
540
|
+
*/
|
|
541
|
+
async _serviceTokenForAnonymousRequest(): Promise<string | null> {
|
|
542
|
+
if (Date.now() < this._anonymousServiceTokenRetryAt) return null;
|
|
543
|
+
const hasKeyPair = Boolean(this._serviceApiKey && this._serviceApiSecret);
|
|
544
|
+
if (!hasKeyPair && !(await this._canUseWorkloadIdentity())) return null;
|
|
545
|
+
try {
|
|
546
|
+
return await this.getServiceToken();
|
|
547
|
+
} catch (error) {
|
|
548
|
+
this._anonymousServiceTokenRetryAt = Date.now() + ANONYMOUS_SERVICE_TOKEN_RETRY_MS;
|
|
549
|
+
logger.warn('[oxy.auth] No service token for a request without a session; sending it anonymous', {
|
|
550
|
+
component: 'auth',
|
|
551
|
+
method: '_serviceTokenForAnonymousRequest',
|
|
552
|
+
retryInMs: ANONYMOUS_SERVICE_TOKEN_RETRY_MS,
|
|
553
|
+
error: error instanceof Error ? error.message : String(error),
|
|
554
|
+
});
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
502
557
|
}
|
|
503
558
|
|
|
504
559
|
/**
|
package/src/models/interfaces.ts
CHANGED
|
@@ -22,6 +22,27 @@ export interface OxyConfig {
|
|
|
22
22
|
* bounce (which uses the RP origin, not this registered client id).
|
|
23
23
|
*/
|
|
24
24
|
clientId?: string;
|
|
25
|
+
/**
|
|
26
|
+
* How a BACKEND identifies itself on requests that carry no user session.
|
|
27
|
+
*
|
|
28
|
+
* - `'never'` (default): such requests go out anonymous. Right for browsers
|
|
29
|
+
* and apps, which have no service identity to offer.
|
|
30
|
+
* - `'when-anonymous'`: they carry this process's service token instead —
|
|
31
|
+
* from `configureServiceAuth()`'s key pair, or, with none, from workload
|
|
32
|
+
* attestation (ADR 0026). A user session, when there is one, still wins,
|
|
33
|
+
* and a process that can produce no service token (a local checkout) keeps
|
|
34
|
+
* sending the request anonymous.
|
|
35
|
+
*
|
|
36
|
+
* Why a server wants it: oxy-api charges anonymous traffic to the SOURCE
|
|
37
|
+
* ADDRESS (`rl:general`, and a +500 ms `slowDown` per request past 100 in 15
|
|
38
|
+
* minutes), and a fleet leaves through one NAT address, so every anonymous
|
|
39
|
+
* read a backend makes shares — and past the threshold pays for — one budget.
|
|
40
|
+
* A first-party service token is exempt from both and charged to its own
|
|
41
|
+
* application's budget instead. Measured from Mention's task (2026-09-25):
|
|
42
|
+
* `GET /users/:id` 543–575 ms anonymous past the threshold, `POST
|
|
43
|
+
* /users/by-ids` 19–24 ms with the service token.
|
|
44
|
+
*/
|
|
45
|
+
serviceIdentity?: 'never' | 'when-anonymous';
|
|
25
46
|
// Performance & caching options
|
|
26
47
|
/**
|
|
27
48
|
* Enable the per-instance GET response cache. Defaults to `true` (5-minute
|