@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.
@@ -233,6 +233,7 @@ export class HttpService {
233
233
  this.noFresherTokenThan = null;
234
234
  this.authRefreshHandler = null;
235
235
  this.accessTokenProvider = null;
236
+ this.anonymousAuthProvider = null;
236
237
  this.deviceSecretMintInFlight = null;
237
238
  /**
238
239
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -918,7 +919,7 @@ export class HttpService {
918
919
  async getAuthHeader() {
919
920
  const accessToken = this.syncAccessTokenFromProvider();
920
921
  if (!accessToken) {
921
- return null;
922
+ return this.getAnonymousAuthHeader();
922
923
  }
923
924
  try {
924
925
  const decoded = jwtDecode(accessToken);
@@ -947,6 +948,24 @@ export class HttpService {
947
948
  return null;
948
949
  }
949
950
  }
951
+ /**
952
+ * The bearer for a request that has no user session behind it, from the
953
+ * {@link AnonymousAuthProvider} if one is installed. Never throws: a provider
954
+ * that fails sends the request anonymous, exactly as it went before any
955
+ * provider existed.
956
+ */
957
+ async getAnonymousAuthHeader() {
958
+ if (!this.anonymousAuthProvider)
959
+ return null;
960
+ try {
961
+ const token = await this.anonymousAuthProvider();
962
+ return token ? `Bearer ${token}` : null;
963
+ }
964
+ catch (error) {
965
+ this.logger.debug('Anonymous auth provider failed; sending the request anonymous:', error);
966
+ return null;
967
+ }
968
+ }
950
969
  async refreshAccessToken(reason) {
951
970
  if (!this.authRefreshHandler) {
952
971
  return null;
@@ -1255,6 +1274,16 @@ export class HttpService {
1255
1274
  setAccessTokenProvider(provider) {
1256
1275
  this.accessTokenProvider = provider;
1257
1276
  }
1277
+ /**
1278
+ * Authenticate requests that carry no user session with the bearer this
1279
+ * provider returns — in practice a backend's own service token (see
1280
+ * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
1281
+ * consulted only when there is no access token, and never for `skipAuth`
1282
+ * requests. `null` removes it.
1283
+ */
1284
+ setAnonymousAuthProvider(provider) {
1285
+ this.anonymousAuthProvider = provider;
1286
+ }
1258
1287
  clearTokens() {
1259
1288
  this.tokenStore.clearTokens();
1260
1289
  // Drop the response cache on logout. The cache is identity-scoped, so a
@@ -85,6 +85,11 @@ export class ServiceCredentialMismatchError extends Error {
85
85
  this.name = 'ServiceCredentialMismatchError';
86
86
  }
87
87
  }
88
+ /**
89
+ * How long a failed service-token mint sends session-less requests anonymous
90
+ * before the next attempt, under `serviceIdentity: 'when-anonymous'`.
91
+ */
92
+ export const ANONYMOUS_SERVICE_TOKEN_RETRY_MS = 30000;
88
93
  export function OxyServicesAuthMixin(Base) {
89
94
  return class extends Base {
90
95
  constructor(...args) {
@@ -111,6 +116,56 @@ export function OxyServicesAuthMixin(Base) {
111
116
  this._serviceApiKey = null;
112
117
  /** @internal Raw apiSecret stored by configureServiceAuth() for use by getServiceToken() */
113
118
  this._serviceApiSecret = null;
119
+ /**
120
+ * Before this instant, a request without a user session does not try to
121
+ * mint a service token and goes out anonymous. Set after a failed mint; see
122
+ * {@link _serviceTokenForAnonymousRequest}.
123
+ *
124
+ * @internal
125
+ */
126
+ this._anonymousServiceTokenRetryAt = 0;
127
+ if (this.config.serviceIdentity === 'when-anonymous') {
128
+ this.httpService.setAnonymousAuthProvider(() => this._serviceTokenForAnonymousRequest());
129
+ }
130
+ }
131
+ /**
132
+ * The bearer for a request with no user session, under `serviceIdentity:
133
+ * 'when-anonymous'`: this process's service token, or `null` to send the
134
+ * request anonymous as before.
135
+ *
136
+ * `null`, never a throw, whenever there is no token to offer:
137
+ * - no key pair and no attestable workload (a local checkout) — asking
138
+ * costs nothing and there is nothing to ask for;
139
+ * - the mint failed. The request still goes out, anonymous, and so does
140
+ * every request for the next {@link ANONYMOUS_SERVICE_TOKEN_RETRY_MS}, so
141
+ * an Oxy that is refusing attestation (seen for ~2.5 minutes after a
142
+ * deploy on 2026-09-25) is asked once per window rather than once per
143
+ * read, and no read waits on a mint that just failed.
144
+ *
145
+ * The token itself comes from {@link getServiceToken}: cached for its
146
+ * lifetime and single-flight, so concurrent reads share one mint.
147
+ *
148
+ * @internal
149
+ */
150
+ async _serviceTokenForAnonymousRequest() {
151
+ if (Date.now() < this._anonymousServiceTokenRetryAt)
152
+ return null;
153
+ const hasKeyPair = Boolean(this._serviceApiKey && this._serviceApiSecret);
154
+ if (!hasKeyPair && !(await this._canUseWorkloadIdentity()))
155
+ return null;
156
+ try {
157
+ return await this.getServiceToken();
158
+ }
159
+ catch (error) {
160
+ this._anonymousServiceTokenRetryAt = Date.now() + ANONYMOUS_SERVICE_TOKEN_RETRY_MS;
161
+ logger.warn('[oxy.auth] No service token for a request without a session; sending it anonymous', {
162
+ component: 'auth',
163
+ method: '_serviceTokenForAnonymousRequest',
164
+ retryInMs: ANONYMOUS_SERVICE_TOKEN_RETRY_MS,
165
+ error: error instanceof Error ? error.message : String(error),
166
+ });
167
+ return null;
168
+ }
114
169
  }
115
170
  /**
116
171
  * Hash an apiKey into a stable Map cache key. Uses Node's SHA-256 — service