@oxyhq/core 1.11.13 → 1.11.14
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/index.js +5 -2
- package/dist/cjs/mixins/OxyServices.auth.js +133 -27
- package/dist/cjs/mixins/OxyServices.utility.js +405 -75
- package/dist/cjs/utils/languageUtils.js +22 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.auth.js +131 -27
- package/dist/esm/mixins/OxyServices.utility.js +405 -75
- package/dist/esm/utils/languageUtils.js +21 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +14 -4
- package/dist/types/index.d.ts +3 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +72 -11
- package/dist/types/mixins/OxyServices.utility.d.ts +144 -10
- package/dist/types/utils/languageUtils.d.ts +1 -0
- package/package.json +18 -2
- package/src/OxyServices.ts +17 -3
- package/src/index.ts +3 -1
- package/src/mixins/OxyServices.auth.ts +160 -28
- package/src/mixins/OxyServices.utility.ts +551 -87
- package/src/mixins/__tests__/serviceAuth.test.ts +623 -0
- package/src/utils/languageUtils.ts +23 -2
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Authentication Methods Mixin
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
4
|
* Supports password-based login (email/username) and public key challenge-response.
|
|
5
5
|
*/
|
|
6
6
|
import type { User } from '../models/interfaces';
|
|
7
7
|
import type { SessionLoginResponse } from '../models/session';
|
|
8
8
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
9
9
|
import { OxyAuthenticationError } from '../OxyServices.errors';
|
|
10
|
+
import { loadNodeCrypto } from '../utils/platformCrypto';
|
|
11
|
+
import { logger } from '../utils/loggerUtils';
|
|
10
12
|
|
|
11
13
|
export interface ChallengeResponse {
|
|
12
14
|
challenge: string;
|
|
@@ -41,45 +43,112 @@ export interface ServiceTokenResponse {
|
|
|
41
43
|
appName: string;
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* One cache entry per (apiKey hash) → issued token + the secret that produced it.
|
|
48
|
+
* The secret is kept around in raw Buffer form so we can perform a
|
|
49
|
+
* constant-time compare against any reused credential pair — this prevents an
|
|
50
|
+
* attacker who learned a victim's apiKey from receiving the victim's cached
|
|
51
|
+
* service token by simply guessing the secret.
|
|
52
|
+
*
|
|
53
|
+
* @internal
|
|
54
|
+
*/
|
|
55
|
+
interface ServiceTokenCacheEntry {
|
|
56
|
+
token: string;
|
|
57
|
+
/** Expiry as ms since epoch */
|
|
58
|
+
expiresAt: number;
|
|
59
|
+
/** Raw secret stored as Buffer for constant-time comparison on cache hit */
|
|
60
|
+
secretBuf: Buffer;
|
|
61
|
+
/** In-flight refresh promise (deduplicates concurrent callers) */
|
|
62
|
+
pending: Promise<string> | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Sentinel error raised when getServiceToken() is called with a known apiKey
|
|
67
|
+
* but a non-matching secret. Indicates either credential drift in the caller
|
|
68
|
+
* or a cross-tenant cache lookup attempt. Surface as a 401-equivalent.
|
|
69
|
+
*/
|
|
70
|
+
export class ServiceCredentialMismatchError extends Error {
|
|
71
|
+
constructor() {
|
|
72
|
+
super('Service credential mismatch: provided secret does not match the secret stored for this apiKey');
|
|
73
|
+
this.name = 'ServiceCredentialMismatchError';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
44
77
|
export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
45
78
|
return class extends Base {
|
|
46
|
-
/** @internal */ _serviceToken: string | null = null;
|
|
47
|
-
/** @internal */ _serviceTokenExp: number = 0;
|
|
48
|
-
/** @internal */ _serviceApiKey: string | null = null;
|
|
49
|
-
/** @internal */ _serviceApiSecret: string | null = null;
|
|
50
79
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
80
|
+
* Per-credential token cache.
|
|
81
|
+
*
|
|
82
|
+
* Keyed by SHA-256(apiKey). Each entry carries:
|
|
83
|
+
* - the issued service JWT
|
|
84
|
+
* - its expiry timestamp
|
|
85
|
+
* - the secret that produced it (Buffer for constant-time compare)
|
|
86
|
+
* - an optional in-flight promise to deduplicate concurrent refreshes
|
|
87
|
+
*
|
|
88
|
+
* The previous implementation kept ONE token/exp pair per OxyServices
|
|
89
|
+
* instance. That meant calling `getServiceToken(keyA, secretA)` populated
|
|
90
|
+
* the cache, and a subsequent `getServiceToken(keyB, secretB)` (different
|
|
91
|
+
* tenant) would receive tenant A's token. This is fixed by routing every
|
|
92
|
+
* lookup through the Map.
|
|
93
|
+
*
|
|
53
94
|
* @internal
|
|
54
95
|
*/
|
|
55
|
-
|
|
96
|
+
_serviceTokenCache = new Map<string, ServiceTokenCacheEntry>();
|
|
97
|
+
|
|
98
|
+
/** @internal Raw apiKey stored by configureServiceAuth() for use by getServiceToken() */
|
|
99
|
+
_serviceApiKey: string | null = null;
|
|
100
|
+
/** @internal Raw apiSecret stored by configureServiceAuth() for use by getServiceToken() */
|
|
101
|
+
_serviceApiSecret: string | null = null;
|
|
56
102
|
|
|
57
103
|
constructor(...args: any[]) {
|
|
58
104
|
super(...(args as [any]));
|
|
59
105
|
}
|
|
60
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Hash an apiKey into a stable Map cache key. Uses Node's SHA-256 — service
|
|
109
|
+
* tokens are only ever issued by a Node host (the SDK on web/RN never has
|
|
110
|
+
* the apiSecret in the first place), so we can rely on Node crypto here.
|
|
111
|
+
*
|
|
112
|
+
* @internal
|
|
113
|
+
*/
|
|
114
|
+
async _hashApiKey(apiKey: string): Promise<string> {
|
|
115
|
+
const nodeCrypto = await loadNodeCrypto();
|
|
116
|
+
return nodeCrypto.createHash('sha256').update(apiKey).digest('hex');
|
|
117
|
+
}
|
|
118
|
+
|
|
61
119
|
/**
|
|
62
120
|
* Configure service credentials for internal service-to-service communication.
|
|
63
121
|
* Call this once at startup so that getServiceToken() and makeServiceRequest()
|
|
64
122
|
* can automatically obtain and refresh tokens.
|
|
65
123
|
*
|
|
124
|
+
* Calling this with credentials that differ from a previously-configured pair
|
|
125
|
+
* is allowed — each `(apiKey, apiSecret)` pair is cached independently, so
|
|
126
|
+
* legitimate multi-tenant hosts that need to switch credentials cannot leak
|
|
127
|
+
* one tenant's token to another tenant on the same instance.
|
|
128
|
+
*
|
|
66
129
|
* @param apiKey - DeveloperApp API key (oxy_dk_*)
|
|
67
130
|
* @param apiSecret - DeveloperApp API secret
|
|
68
131
|
*/
|
|
69
132
|
configureServiceAuth(apiKey: string, apiSecret: string): void {
|
|
70
133
|
this._serviceApiKey = apiKey;
|
|
71
134
|
this._serviceApiSecret = apiSecret;
|
|
72
|
-
// Invalidate any cached token
|
|
73
|
-
this._serviceToken = null;
|
|
74
|
-
this._serviceTokenExp = 0;
|
|
75
135
|
}
|
|
76
136
|
|
|
77
137
|
/**
|
|
78
138
|
* Get a service token for internal service-to-service communication.
|
|
79
|
-
* Tokens are short-lived (1h) and automatically cached/refreshed
|
|
139
|
+
* Tokens are short-lived (1h) and automatically cached/refreshed per
|
|
140
|
+
* `(apiKey, apiSecret)` pair.
|
|
141
|
+
*
|
|
142
|
+
* Concurrent callers for the same credential pair share a single in-flight
|
|
143
|
+
* request to avoid hammering `/auth/service-token` when the cache is empty
|
|
144
|
+
* or expired.
|
|
80
145
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
146
|
+
* **Security guarantee:** if the cache already holds a token for this
|
|
147
|
+
* apiKey but the supplied apiSecret does not constant-time match the
|
|
148
|
+
* secret that originally produced that token, this method throws
|
|
149
|
+
* `ServiceCredentialMismatchError` instead of returning the cached token.
|
|
150
|
+
* This prevents an attacker who learned a peer's apiKey from extracting
|
|
151
|
+
* their service token by polling with a wrong secret.
|
|
83
152
|
*
|
|
84
153
|
* @param apiKey - DeveloperApp API key (optional if configureServiceAuth was called)
|
|
85
154
|
* @param apiSecret - DeveloperApp API secret (optional if configureServiceAuth was called)
|
|
@@ -92,21 +161,65 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
92
161
|
throw new Error('Service credentials not provided. Call configureServiceAuth() or pass apiKey and apiSecret.');
|
|
93
162
|
}
|
|
94
163
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
164
|
+
const cacheKey = await this._hashApiKey(key);
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
const providedSecretBuf = Buffer.from(secret, 'utf8');
|
|
167
|
+
|
|
168
|
+
let entry = this._serviceTokenCache.get(cacheKey);
|
|
169
|
+
|
|
170
|
+
// Verify the secret on every cache hit, regardless of token freshness.
|
|
171
|
+
// Constant-time compare prevents timing oracles on the stored secret.
|
|
172
|
+
if (entry) {
|
|
173
|
+
const nodeCrypto = await loadNodeCrypto();
|
|
174
|
+
const storedSecretBuf = entry.secretBuf;
|
|
175
|
+
const lengthMatch = storedSecretBuf.length === providedSecretBuf.length;
|
|
176
|
+
// Always run timingSafeEqual on equal-length inputs to keep timing flat.
|
|
177
|
+
// When lengths differ, run against a zero-padded copy of the same length
|
|
178
|
+
// to avoid an early-return timing signal.
|
|
179
|
+
const compareBuf = lengthMatch
|
|
180
|
+
? providedSecretBuf
|
|
181
|
+
: Buffer.alloc(storedSecretBuf.length);
|
|
182
|
+
const compareResult = nodeCrypto.timingSafeEqual(storedSecretBuf, compareBuf);
|
|
183
|
+
if (!lengthMatch || !compareResult) {
|
|
184
|
+
logger.warn('[oxy.auth] Service token cache hit with mismatched secret', {
|
|
185
|
+
component: 'auth',
|
|
186
|
+
method: 'getServiceToken',
|
|
187
|
+
});
|
|
188
|
+
throw new ServiceCredentialMismatchError();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Return cached token if still valid (with 60s buffer for clock drift)
|
|
192
|
+
if (entry.token && entry.expiresAt > now + 60_000) {
|
|
193
|
+
return entry.token;
|
|
194
|
+
}
|
|
99
195
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
196
|
+
// If a fetch is already in-flight for this credential, share its result
|
|
197
|
+
if (entry.pending) {
|
|
198
|
+
return entry.pending;
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
// First time seeing this apiKey on this instance — seed an empty entry
|
|
202
|
+
// so concurrent callers serialize on the same promise.
|
|
203
|
+
entry = {
|
|
204
|
+
token: '',
|
|
205
|
+
expiresAt: 0,
|
|
206
|
+
secretBuf: providedSecretBuf,
|
|
207
|
+
pending: null,
|
|
208
|
+
};
|
|
209
|
+
this._serviceTokenCache.set(cacheKey, entry);
|
|
103
210
|
}
|
|
104
211
|
|
|
105
|
-
|
|
212
|
+
const pending = this._doFetchServiceToken(key, secret, cacheKey, providedSecretBuf);
|
|
213
|
+
entry.pending = pending;
|
|
106
214
|
try {
|
|
107
|
-
return await
|
|
215
|
+
return await pending;
|
|
108
216
|
} finally {
|
|
109
|
-
|
|
217
|
+
// Clear the in-flight slot; the entry itself (with fresh token / expiry)
|
|
218
|
+
// is updated inside _doFetchServiceToken before we land here.
|
|
219
|
+
const settled = this._serviceTokenCache.get(cacheKey);
|
|
220
|
+
if (settled) {
|
|
221
|
+
settled.pending = null;
|
|
222
|
+
}
|
|
110
223
|
}
|
|
111
224
|
}
|
|
112
225
|
|
|
@@ -115,7 +228,12 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
115
228
|
* Separated so getServiceToken() can deduplicate concurrent calls.
|
|
116
229
|
* @internal
|
|
117
230
|
*/
|
|
118
|
-
async _doFetchServiceToken(
|
|
231
|
+
async _doFetchServiceToken(
|
|
232
|
+
key: string,
|
|
233
|
+
secret: string,
|
|
234
|
+
cacheKey: string,
|
|
235
|
+
secretBuf: Buffer,
|
|
236
|
+
): Promise<string> {
|
|
119
237
|
const response = await this.makeRequest<ServiceTokenResponse>(
|
|
120
238
|
'POST',
|
|
121
239
|
'/auth/service-token',
|
|
@@ -123,10 +241,24 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
123
241
|
{ cache: false, retry: false }
|
|
124
242
|
);
|
|
125
243
|
|
|
126
|
-
|
|
127
|
-
|
|
244
|
+
const expiresAt = Date.now() + response.expiresIn * 1000;
|
|
245
|
+
// Update the entry in-place so any caller that already grabbed a reference
|
|
246
|
+
// (via `_serviceTokenCache.get(...)`) sees the fresh state.
|
|
247
|
+
const entry = this._serviceTokenCache.get(cacheKey);
|
|
248
|
+
if (entry) {
|
|
249
|
+
entry.token = response.token;
|
|
250
|
+
entry.expiresAt = expiresAt;
|
|
251
|
+
entry.secretBuf = secretBuf;
|
|
252
|
+
} else {
|
|
253
|
+
this._serviceTokenCache.set(cacheKey, {
|
|
254
|
+
token: response.token,
|
|
255
|
+
expiresAt,
|
|
256
|
+
secretBuf,
|
|
257
|
+
pending: null,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
128
260
|
|
|
129
|
-
return
|
|
261
|
+
return response.token;
|
|
130
262
|
}
|
|
131
263
|
|
|
132
264
|
/**
|