@oxyhq/core 12.7.0 → 12.9.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/boot/sessionColdBoot.js +16 -3
- package/dist/cjs/crypto/identityMarker.js +255 -0
- package/dist/cjs/crypto/keyManager.js +844 -106
- package/dist/cjs/index.js +8 -4
- package/dist/cjs/mixins/OxyServices.auth.js +21 -6
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/cjs/mixins/OxyServices.utility.js +11 -1
- package/dist/cjs/server/auth.js +3 -0
- package/dist/cjs/server/index.js +2 -1
- package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/sessionColdBoot.js +16 -3
- package/dist/esm/crypto/identityMarker.js +248 -0
- package/dist/esm/crypto/keyManager.js +843 -106
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.auth.js +21 -6
- package/dist/esm/mixins/OxyServices.deviceBoot.js +9 -1
- package/dist/esm/mixins/OxyServices.utility.js +11 -1
- package/dist/esm/server/auth.js +2 -0
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/sessionColdBoot.d.ts +25 -0
- package/dist/types/crypto/identityMarker.d.ts +94 -0
- package/dist/types/crypto/keyManager.d.ts +212 -3
- package/dist/types/index.d.ts +4 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +27 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +8 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -0
- package/dist/types/server/auth.d.ts +4 -0
- package/dist/types/server/index.d.ts +2 -2
- package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
- package/package.json +1 -1
- package/src/boot/__tests__/sessionColdBoot.test.ts +113 -0
- package/src/boot/sessionColdBoot.ts +42 -3
- package/src/crypto/__tests__/identityMocks.ts +125 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +79 -94
- package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
- package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
- package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
- package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
- package/src/crypto/__tests__/keyManager.test.ts +77 -87
- package/src/crypto/identityMarker.ts +291 -0
- package/src/crypto/keyManager.ts +1026 -105
- package/src/index.ts +7 -1
- package/src/mixins/OxyServices.auth.ts +31 -7
- package/src/mixins/OxyServices.deviceBoot.ts +9 -1
- package/src/mixins/OxyServices.utility.ts +19 -1
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +4 -2
- package/src/mixins/__tests__/commonsSignIn.test.ts +84 -1
- package/src/mixins/__tests__/serviceAuth.test.ts +65 -0
- package/src/server/auth.ts +5 -0
- package/src/server/index.ts +2 -0
- package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
- package/src/utils/oxyServiceEnvironment.ts +17 -0
package/src/index.ts
CHANGED
|
@@ -245,8 +245,14 @@ export {
|
|
|
245
245
|
KeyManager,
|
|
246
246
|
IdentityAlreadyExistsError,
|
|
247
247
|
IdentityPersistError,
|
|
248
|
+
IdentityUnavailableError,
|
|
248
249
|
} from './crypto/keyManager';
|
|
249
|
-
export type { KeyPair } from './crypto/keyManager';
|
|
250
|
+
export type { KeyPair, IdentityStatus, IdentityRecoveryResult } from './crypto/keyManager';
|
|
251
|
+
export {
|
|
252
|
+
readIdentityMarker,
|
|
253
|
+
updateIdentityMarker,
|
|
254
|
+
} from './crypto/identityMarker';
|
|
255
|
+
export type { IdentityMarker } from './crypto/identityMarker';
|
|
250
256
|
export { SignatureService } from './crypto/signatureService';
|
|
251
257
|
export type { SignedMessage, AuthChallenge } from './crypto/signatureService';
|
|
252
258
|
export { RecoveryPhraseService } from './crypto/recoveryPhrase';
|
|
@@ -497,14 +497,21 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
497
497
|
/**
|
|
498
498
|
* Request an authentication challenge
|
|
499
499
|
* The client must sign this challenge with their private key
|
|
500
|
-
*
|
|
500
|
+
*
|
|
501
501
|
* @param publicKey - The user's public key
|
|
502
|
+
* @param requestOptions - Optional per-call transport overrides (`retry`,
|
|
503
|
+
* `timeout`). Interactive callers omit it (defaults keep retries); the
|
|
504
|
+
* cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
|
|
505
|
+
* network cannot multiply boot latency via the inner retry loop.
|
|
502
506
|
*/
|
|
503
|
-
async requestChallenge(
|
|
507
|
+
async requestChallenge(
|
|
508
|
+
publicKey: string,
|
|
509
|
+
requestOptions?: { retry?: boolean; timeout?: number },
|
|
510
|
+
): Promise<ChallengeResponse> {
|
|
504
511
|
try {
|
|
505
512
|
return await this.makeRequest<ChallengeResponse>('POST', '/auth/challenge', {
|
|
506
513
|
publicKey,
|
|
507
|
-
}, { cache: false });
|
|
514
|
+
}, { cache: false, ...requestOptions });
|
|
508
515
|
} catch (error) {
|
|
509
516
|
throw this.handleError(error);
|
|
510
517
|
}
|
|
@@ -519,6 +526,10 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
519
526
|
* @param timestamp - Timestamp when the signature was created
|
|
520
527
|
* @param deviceName - Optional device name
|
|
521
528
|
* @param deviceFingerprint - Optional device fingerprint
|
|
529
|
+
* @param requestOptions - Optional per-call transport overrides (`retry`,
|
|
530
|
+
* `timeout`). Interactive callers omit it (defaults keep retries); the
|
|
531
|
+
* cold-boot `shared-key-signin` step passes `{ retry: false }` so a slow
|
|
532
|
+
* network cannot multiply boot latency via the inner retry loop.
|
|
522
533
|
*/
|
|
523
534
|
async verifyChallenge(
|
|
524
535
|
publicKey: string,
|
|
@@ -526,7 +537,8 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
526
537
|
signature: string,
|
|
527
538
|
timestamp: number,
|
|
528
539
|
deviceName?: string,
|
|
529
|
-
deviceFingerprint?: string
|
|
540
|
+
deviceFingerprint?: string,
|
|
541
|
+
requestOptions?: { retry?: boolean; timeout?: number },
|
|
530
542
|
): Promise<SessionLoginResponse> {
|
|
531
543
|
try {
|
|
532
544
|
const res = await this.makeRequest<SessionLoginResponse>('POST', '/auth/verify', {
|
|
@@ -536,7 +548,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
536
548
|
timestamp,
|
|
537
549
|
deviceName,
|
|
538
550
|
deviceFingerprint,
|
|
539
|
-
}, { cache: false });
|
|
551
|
+
}, { cache: false, ...requestOptions });
|
|
540
552
|
|
|
541
553
|
// Plant the freshly-minted tokens, mirroring `claimSessionByToken`.
|
|
542
554
|
// `/auth/verify` returns the first access token (and refresh token) in
|
|
@@ -718,9 +730,20 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
718
730
|
*
|
|
719
731
|
* The cold-boot wiring that CALLS this lives in `OxyContext`
|
|
720
732
|
* (`@oxyhq/services`); this method just performs the exchange.
|
|
733
|
+
*
|
|
734
|
+
* @param opts.requestOptions - Optional per-call transport overrides
|
|
735
|
+
* (`retry`, `timeout`) forwarded to BOTH the `requestChallenge` and
|
|
736
|
+
* `verifyChallenge` round-trips. Interactive flows omit it (defaults keep
|
|
737
|
+
* retries); the cold-boot `shared-key-signin` step passes `{ retry: false }`
|
|
738
|
+
* so this network step cannot multiply boot latency via the inner retry
|
|
739
|
+
* loop. The token-refresh scheduler / 401 lane still retry later.
|
|
721
740
|
*/
|
|
722
741
|
async signInWithSharedIdentity(
|
|
723
|
-
opts: {
|
|
742
|
+
opts: {
|
|
743
|
+
deviceName?: string;
|
|
744
|
+
deviceFingerprint?: string;
|
|
745
|
+
requestOptions?: { retry?: boolean; timeout?: number };
|
|
746
|
+
} = {}
|
|
724
747
|
): Promise<SessionLoginResponse | null> {
|
|
725
748
|
try {
|
|
726
749
|
// `hasSharedIdentity()` already returns false on web (the shared
|
|
@@ -734,7 +757,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
734
757
|
return null;
|
|
735
758
|
}
|
|
736
759
|
|
|
737
|
-
const { challenge } = await this.requestChallenge(sharedPublicKey);
|
|
760
|
+
const { challenge } = await this.requestChallenge(sharedPublicKey, opts.requestOptions);
|
|
738
761
|
const signed = await SignatureService.signChallengeWithSharedKey(challenge);
|
|
739
762
|
|
|
740
763
|
// `signed.challenge` carries the SIGNATURE (mirrors `signChallenge`).
|
|
@@ -745,6 +768,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
745
768
|
signed.timestamp,
|
|
746
769
|
opts.deviceName,
|
|
747
770
|
opts.deviceFingerprint,
|
|
771
|
+
opts.requestOptions,
|
|
748
772
|
);
|
|
749
773
|
} catch (error) {
|
|
750
774
|
throw this.handleError(error);
|
|
@@ -39,6 +39,14 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
39
39
|
* `no_active_session`) to decide whether to drop the secret and fall back or
|
|
40
40
|
* resolve signed-out.
|
|
41
41
|
*
|
|
42
|
+
* `retry: false`: the mint is a single logical attempt. The proactive
|
|
43
|
+
* token-refresh scheduler and the reactive 401 lane already own backoff and
|
|
44
|
+
* re-arm, so `HttpService`'s inner retry loop here would only multiply the
|
|
45
|
+
* mint's latency on a slow/black-hole network (3 retries × 5s timeout ≈ 20s
|
|
46
|
+
* per lane) with no correctness benefit — it is the dominant term in the cold
|
|
47
|
+
* boot's worst-case time-to-route. A transient failure surfaces once and the
|
|
48
|
+
* scheduler/401 path retries it later.
|
|
49
|
+
*
|
|
42
50
|
* @throws if the response does not match {@link deviceTokenMintResponseSchema}.
|
|
43
51
|
*/
|
|
44
52
|
async mintFromDeviceSecret(
|
|
@@ -50,7 +58,7 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
|
|
|
50
58
|
'POST',
|
|
51
59
|
'/session/device/token',
|
|
52
60
|
{ deviceId, deviceSecret },
|
|
53
|
-
{ cache: false, skipAuth: true },
|
|
61
|
+
{ cache: false, skipAuth: true, retry: false },
|
|
54
62
|
);
|
|
55
63
|
const parsed = safeParseContract(deviceTokenMintResponseSchema, res);
|
|
56
64
|
if (!parsed) {
|
|
@@ -12,6 +12,7 @@ import { loadNodeCrypto } from '@oxyhq/protocol';
|
|
|
12
12
|
import { buildUrl } from '../utils/apiUtils';
|
|
13
13
|
import { logger } from '../logger';
|
|
14
14
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
15
|
+
import { OXY_SERVICE_ENVIRONMENTS, type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
|
|
15
16
|
|
|
16
17
|
interface JwtPayload {
|
|
17
18
|
exp?: number;
|
|
@@ -25,6 +26,7 @@ interface JwtPayload {
|
|
|
25
26
|
scopes?: string[];
|
|
26
27
|
aud?: string | string[];
|
|
27
28
|
iss?: string;
|
|
29
|
+
environment?: string;
|
|
28
30
|
[key: string]: unknown;
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -57,6 +59,8 @@ export interface ServiceApp {
|
|
|
57
59
|
scopes: string[];
|
|
58
60
|
/** The credentialId of the specific service credential that minted this token. */
|
|
59
61
|
credentialId: string;
|
|
62
|
+
/** Test/live isolation (F2.0): which `ApplicationCredential.environment` minted this token. */
|
|
63
|
+
environment: OxyServiceEnvironment;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/**
|
|
@@ -94,6 +98,13 @@ class ServiceTokenClaimError extends Error {
|
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
function isOxyServiceEnvironment(value: unknown): value is OxyServiceEnvironment {
|
|
102
|
+
return (
|
|
103
|
+
typeof value === 'string' &&
|
|
104
|
+
(OXY_SERVICE_ENVIRONMENTS as readonly string[]).includes(value)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
/**
|
|
98
109
|
* Options for oxyClient.auth() middleware
|
|
99
110
|
*/
|
|
@@ -459,7 +470,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
459
470
|
// Validate required service token fields
|
|
460
471
|
const appId = decoded.appId;
|
|
461
472
|
const credentialId = decoded.credentialId;
|
|
462
|
-
|
|
473
|
+
const environment = decoded.environment;
|
|
474
|
+
if (
|
|
475
|
+
!appId ||
|
|
476
|
+
typeof credentialId !== 'string' ||
|
|
477
|
+
credentialId.length === 0 ||
|
|
478
|
+
!isOxyServiceEnvironment(environment)
|
|
479
|
+
) {
|
|
463
480
|
if (optional) {
|
|
464
481
|
req.userId = null;
|
|
465
482
|
req.user = null;
|
|
@@ -513,6 +530,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
513
530
|
appName: decoded.appName || 'unknown',
|
|
514
531
|
credentialId,
|
|
515
532
|
scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
|
|
533
|
+
environment,
|
|
516
534
|
};
|
|
517
535
|
|
|
518
536
|
if (debug) {
|
|
@@ -31,15 +31,17 @@ describe('OxyServices.deviceBoot', () => {
|
|
|
31
31
|
},
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
it('POSTs deviceId + deviceSecret with skipAuth (no bearer, no cache) and returns the validated mint', async () => {
|
|
34
|
+
it('POSTs deviceId + deviceSecret with skipAuth + retry:false (no bearer, no cache, single attempt) and returns the validated mint', async () => {
|
|
35
35
|
makeRequest.mockResolvedValueOnce(MINT);
|
|
36
36
|
const result = await oxy.mintFromDeviceSecret('dev-1', 'ds-current-secret');
|
|
37
37
|
expect(result).toEqual(MINT);
|
|
38
|
+
// `retry: false` — the scheduler/401 lane own backoff; HttpService's inner
|
|
39
|
+
// retry here would only multiply cold-boot latency on a slow network.
|
|
38
40
|
expect(makeRequest).toHaveBeenCalledWith(
|
|
39
41
|
'POST',
|
|
40
42
|
'/session/device/token',
|
|
41
43
|
{ deviceId: 'dev-1', deviceSecret: 'ds-current-secret' },
|
|
42
|
-
{ cache: false, skipAuth: true },
|
|
44
|
+
{ cache: false, skipAuth: true, retry: false },
|
|
43
45
|
);
|
|
44
46
|
});
|
|
45
47
|
|
|
@@ -242,6 +242,51 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
|
|
|
242
242
|
});
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
+
describe('requestChallenge / verifyChallenge — requestOptions spread into makeRequest', () => {
|
|
246
|
+
it('requestChallenge omits transport overrides by default (retries ON) and spreads them when given', async () => {
|
|
247
|
+
makeRequestSpy.mockResolvedValue(challengeFixture);
|
|
248
|
+
|
|
249
|
+
await oxy.requestChallenge('pub-x');
|
|
250
|
+
expect(makeRequestSpy).toHaveBeenLastCalledWith(
|
|
251
|
+
'POST',
|
|
252
|
+
'/auth/challenge',
|
|
253
|
+
{ publicKey: 'pub-x' },
|
|
254
|
+
{ cache: false },
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
await oxy.requestChallenge('pub-x', { retry: false });
|
|
258
|
+
expect(makeRequestSpy).toHaveBeenLastCalledWith(
|
|
259
|
+
'POST',
|
|
260
|
+
'/auth/challenge',
|
|
261
|
+
{ publicKey: 'pub-x' },
|
|
262
|
+
{ cache: false, retry: false },
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('verifyChallenge spreads requestOptions (retry + timeout) into makeRequest', async () => {
|
|
267
|
+
makeRequestSpy.mockResolvedValue(sessionFixture);
|
|
268
|
+
|
|
269
|
+
await oxy.verifyChallenge('pub-x', 'chal', 'sig', 123, 'dev', 'fp', {
|
|
270
|
+
retry: false,
|
|
271
|
+
timeout: 9000,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(makeRequestSpy).toHaveBeenLastCalledWith(
|
|
275
|
+
'POST',
|
|
276
|
+
'/auth/verify',
|
|
277
|
+
{
|
|
278
|
+
publicKey: 'pub-x',
|
|
279
|
+
challenge: 'chal',
|
|
280
|
+
signature: 'sig',
|
|
281
|
+
timestamp: 123,
|
|
282
|
+
deviceName: 'dev',
|
|
283
|
+
deviceFingerprint: 'fp',
|
|
284
|
+
},
|
|
285
|
+
{ cache: false, retry: false, timeout: 9000 },
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
245
290
|
describe('signInWithSharedIdentity (Mechanism A — same-device SSO)', () => {
|
|
246
291
|
it('mints a session from the shared key when one exists (native)', async () => {
|
|
247
292
|
jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
|
|
@@ -263,7 +308,9 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
|
|
|
263
308
|
deviceFingerprint: 'fp-1',
|
|
264
309
|
});
|
|
265
310
|
|
|
266
|
-
|
|
311
|
+
// No requestOptions passed → both round-trips get `undefined` (defaults:
|
|
312
|
+
// retries ON), preserving interactive behaviour.
|
|
313
|
+
expect(requestChallengeSpy).toHaveBeenCalledWith('shared-pub', undefined);
|
|
267
314
|
expect(verifyChallengeSpy).toHaveBeenCalledWith(
|
|
268
315
|
'shared-pub',
|
|
269
316
|
'chal-shared',
|
|
@@ -271,6 +318,42 @@ describe('OxyServices — "Sign in with Oxy" handoff', () => {
|
|
|
271
318
|
1700000000456,
|
|
272
319
|
'iPad',
|
|
273
320
|
'fp-1',
|
|
321
|
+
undefined,
|
|
322
|
+
);
|
|
323
|
+
expect(result).toEqual(sessionFixture);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('threads requestOptions into BOTH the challenge and verify round-trips (cold-boot retry:false)', async () => {
|
|
327
|
+
jest.spyOn(KeyManager, 'hasSharedIdentity').mockResolvedValue(true);
|
|
328
|
+
jest.spyOn(KeyManager, 'getSharedPublicKey').mockResolvedValue('shared-pub');
|
|
329
|
+
const requestChallengeSpy = jest
|
|
330
|
+
.spyOn(oxy, 'requestChallenge')
|
|
331
|
+
.mockResolvedValue({ challenge: 'chal-shared', expiresAt: '2026-06-26T00:05:00.000Z' });
|
|
332
|
+
jest.spyOn(SignatureService, 'signChallengeWithSharedKey').mockResolvedValue({
|
|
333
|
+
challenge: 'sig-shared',
|
|
334
|
+
publicKey: 'shared-pub',
|
|
335
|
+
timestamp: 1700000000456,
|
|
336
|
+
});
|
|
337
|
+
const verifyChallengeSpy = jest
|
|
338
|
+
.spyOn(oxy, 'verifyChallenge')
|
|
339
|
+
.mockResolvedValue(sessionFixture);
|
|
340
|
+
|
|
341
|
+
const result = await oxy.signInWithSharedIdentity({
|
|
342
|
+
requestOptions: { retry: false },
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// The SAME requestOptions object is forwarded to both calls — this is how
|
|
346
|
+
// the cold-boot `shared-key-signin` step keeps its two round-trips as
|
|
347
|
+
// single attempts without changing interactive defaults.
|
|
348
|
+
expect(requestChallengeSpy).toHaveBeenCalledWith('shared-pub', { retry: false });
|
|
349
|
+
expect(verifyChallengeSpy).toHaveBeenCalledWith(
|
|
350
|
+
'shared-pub',
|
|
351
|
+
'chal-shared',
|
|
352
|
+
'sig-shared',
|
|
353
|
+
1700000000456,
|
|
354
|
+
undefined,
|
|
355
|
+
undefined,
|
|
356
|
+
{ retry: false },
|
|
274
357
|
);
|
|
275
358
|
expect(result).toEqual(sessionFixture);
|
|
276
359
|
});
|
|
@@ -30,6 +30,7 @@ interface ServiceTokenClaims {
|
|
|
30
30
|
scopes?: string[];
|
|
31
31
|
aud?: string | string[];
|
|
32
32
|
iss?: string;
|
|
33
|
+
environment?: string;
|
|
33
34
|
exp?: number;
|
|
34
35
|
iat?: number;
|
|
35
36
|
[key: string]: unknown;
|
|
@@ -49,6 +50,7 @@ const signServiceToken = (claims: ServiceTokenClaims, secret: string): string =>
|
|
|
49
50
|
aud: 'oxy-api',
|
|
50
51
|
iss: 'oxy-auth',
|
|
51
52
|
credentialId: 'cred-1',
|
|
53
|
+
environment: 'production',
|
|
52
54
|
...claims,
|
|
53
55
|
};
|
|
54
56
|
const headerB64 = b64url(JSON.stringify(header));
|
|
@@ -183,6 +185,7 @@ describe('C3: service-token acting-as enforcement', () => {
|
|
|
183
185
|
appName: 'trusted-service',
|
|
184
186
|
credentialId: 'cred-1',
|
|
185
187
|
scopes: ['user:read'],
|
|
188
|
+
environment: 'production',
|
|
186
189
|
});
|
|
187
190
|
});
|
|
188
191
|
|
|
@@ -762,3 +765,65 @@ describe('requireScope() middleware', () => {
|
|
|
762
765
|
expect(() => oxy.requireScope(undefined as unknown as string)).toThrow('requireScope');
|
|
763
766
|
});
|
|
764
767
|
});
|
|
768
|
+
|
|
769
|
+
// ---------------------------------------------------------------------------
|
|
770
|
+
// service-token environment claim (F2.0 task 1b) — test/live isolation.
|
|
771
|
+
// ---------------------------------------------------------------------------
|
|
772
|
+
|
|
773
|
+
describe('service-token environment claim (F2.0 task 1b)', () => {
|
|
774
|
+
let oxy: OxyServices;
|
|
775
|
+
|
|
776
|
+
beforeEach(() => {
|
|
777
|
+
oxy = new OxyServices({ baseURL: 'http://test.invalid' });
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
it('populates req.serviceApp.environment from the token claim', async () => {
|
|
781
|
+
const token = signServiceToken(
|
|
782
|
+
{ appId: 'app-1', appName: 'svc', environment: 'development' },
|
|
783
|
+
SERVICE_SECRET,
|
|
784
|
+
);
|
|
785
|
+
const req = makeReq({ headers: { authorization: `Bearer ${token}` } });
|
|
786
|
+
const res = makeRes();
|
|
787
|
+
const next = jest.fn();
|
|
788
|
+
|
|
789
|
+
const mw = oxy.auth({ jwtSecret: SERVICE_SECRET });
|
|
790
|
+
await mw(req as unknown as never, res as unknown as never, next as unknown as never);
|
|
791
|
+
|
|
792
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
793
|
+
expect(req.serviceApp).toMatchObject({ appId: 'app-1', environment: 'development' });
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it('rejects a service token missing the environment claim (401)', async () => {
|
|
797
|
+
const token = signServiceToken(
|
|
798
|
+
{ appId: 'app-1', appName: 'svc', environment: undefined },
|
|
799
|
+
SERVICE_SECRET,
|
|
800
|
+
);
|
|
801
|
+
const req = makeReq({ headers: { authorization: `Bearer ${token}` } });
|
|
802
|
+
const res = makeRes();
|
|
803
|
+
const next = jest.fn();
|
|
804
|
+
|
|
805
|
+
const mw = oxy.auth({ jwtSecret: SERVICE_SECRET });
|
|
806
|
+
await mw(req as unknown as never, res as unknown as never, next as unknown as never);
|
|
807
|
+
|
|
808
|
+
expect(next).not.toHaveBeenCalled();
|
|
809
|
+
expect(res.statusCode).toBe(401);
|
|
810
|
+
expect(res.body).toMatchObject({ code: 'INVALID_SERVICE_TOKEN' });
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('rejects a service token with an environment value outside the known set (401)', async () => {
|
|
814
|
+
const token = signServiceToken(
|
|
815
|
+
{ appId: 'app-1', appName: 'svc', environment: 'bogus' },
|
|
816
|
+
SERVICE_SECRET,
|
|
817
|
+
);
|
|
818
|
+
const req = makeReq({ headers: { authorization: `Bearer ${token}` } });
|
|
819
|
+
const res = makeRes();
|
|
820
|
+
const next = jest.fn();
|
|
821
|
+
|
|
822
|
+
const mw = oxy.auth({ jwtSecret: SERVICE_SECRET });
|
|
823
|
+
await mw(req as unknown as never, res as unknown as never, next as unknown as never);
|
|
824
|
+
|
|
825
|
+
expect(next).not.toHaveBeenCalled();
|
|
826
|
+
expect(res.statusCode).toBe(401);
|
|
827
|
+
expect(res.body).toMatchObject({ code: 'INVALID_SERVICE_TOKEN' });
|
|
828
|
+
});
|
|
829
|
+
});
|
package/src/server/auth.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
2
2
|
import type { OxyServices } from '../OxyServices';
|
|
3
|
+
import { OXY_SERVICE_ENVIRONMENTS, type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
|
|
4
|
+
|
|
5
|
+
export { OXY_SERVICE_ENVIRONMENTS };
|
|
6
|
+
export type { OxyServiceEnvironment };
|
|
3
7
|
|
|
4
8
|
export interface OxyRequestUser {
|
|
5
9
|
id: string;
|
|
@@ -15,6 +19,7 @@ export interface OxyServiceAppContext {
|
|
|
15
19
|
appName: string;
|
|
16
20
|
scopes: string[];
|
|
17
21
|
credentialId: string;
|
|
22
|
+
environment: OxyServiceEnvironment;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export interface OxyServiceActingAsContext {
|
package/src/server/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ export {
|
|
|
22
22
|
getRequiredOxyUserId,
|
|
23
23
|
isOxyAuthenticated,
|
|
24
24
|
requireOxyAuth,
|
|
25
|
+
OXY_SERVICE_ENVIRONMENTS,
|
|
25
26
|
} from './auth';
|
|
26
27
|
export type {
|
|
27
28
|
OxyAuthenticatedRequest,
|
|
@@ -30,6 +31,7 @@ export type {
|
|
|
30
31
|
OxyRequestUser,
|
|
31
32
|
OxyServiceActingAsContext,
|
|
32
33
|
OxyServiceAppContext,
|
|
34
|
+
OxyServiceEnvironment,
|
|
33
35
|
} from './auth';
|
|
34
36
|
export { createOxyRateLimit } from './rateLimit';
|
|
35
37
|
export type { OxyRateLimitOptions } from './rateLimit';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { OXY_SERVICE_ENVIRONMENTS } from '../oxyServiceEnvironment';
|
|
2
|
+
|
|
3
|
+
describe('OXY_SERVICE_ENVIRONMENTS', () => {
|
|
4
|
+
it('lists exactly development, staging, production, in that order', () => {
|
|
5
|
+
expect(OXY_SERVICE_ENVIRONMENTS).toEqual(['development', 'staging', 'production']);
|
|
6
|
+
});
|
|
7
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment segregation for Oxy service-token JWTs (test/live isolation).
|
|
3
|
+
* Mirrors `ApplicationCredentialEnvironment` on the API's `ApplicationCredential`
|
|
4
|
+
* model (`packages/api/src/models/ApplicationCredential.ts`) as an INDEPENDENT
|
|
5
|
+
* literal union — `@oxyhq/core` has zero dependency on `@oxyhq/api`, so this is
|
|
6
|
+
* kept in sync by hand, not by import.
|
|
7
|
+
*
|
|
8
|
+
* Defined here (not in `server/auth.ts` or `mixins/OxyServices.utility.ts`
|
|
9
|
+
* directly) because BOTH of those files need it and neither may import from
|
|
10
|
+
* the other: `server/` types import `express` (Node-only, a peer dependency
|
|
11
|
+
* `mixins/` deliberately avoids so it stays safe to bundle into RN/browser
|
|
12
|
+
* consumers — see the "Local request/response/socket typing" comment in
|
|
13
|
+
* `OxyServices.utility.ts`). This file has zero imports, so both sides can
|
|
14
|
+
* depend on it without crossing that boundary.
|
|
15
|
+
*/
|
|
16
|
+
export const OXY_SERVICE_ENVIRONMENTS = ['development', 'staging', 'production'] as const;
|
|
17
|
+
export type OxyServiceEnvironment = (typeof OXY_SERVICE_ENVIRONMENTS)[number];
|