@oxyhq/core 12.7.0 → 12.8.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/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/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/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/mixins/OxyServices.utility.ts +19 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ApiError, User } from '../models/interfaces';
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
|
+
import { type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
|
|
3
4
|
/**
|
|
4
5
|
* Result from the service-acting-as verification endpoint.
|
|
5
6
|
* Confirms that a given service app holds an active delegation grant for
|
|
@@ -28,6 +29,8 @@ export interface ServiceApp {
|
|
|
28
29
|
scopes: string[];
|
|
29
30
|
/** The credentialId of the specific service credential that minted this token. */
|
|
30
31
|
credentialId: string;
|
|
32
|
+
/** Test/live isolation (F2.0): which `ApplicationCredential.environment` minted this token. */
|
|
33
|
+
environment: OxyServiceEnvironment;
|
|
31
34
|
}
|
|
32
35
|
/**
|
|
33
36
|
* Options for oxyClient.auth() middleware
|
|
@@ -1,5 +1,8 @@
|
|
|
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
|
+
export { OXY_SERVICE_ENVIRONMENTS };
|
|
5
|
+
export type { OxyServiceEnvironment };
|
|
3
6
|
export interface OxyRequestUser {
|
|
4
7
|
id: string;
|
|
5
8
|
_id?: string;
|
|
@@ -13,6 +16,7 @@ export interface OxyServiceAppContext {
|
|
|
13
16
|
appName: string;
|
|
14
17
|
scopes: string[];
|
|
15
18
|
credentialId: string;
|
|
19
|
+
environment: OxyServiceEnvironment;
|
|
16
20
|
}
|
|
17
21
|
export interface OxyServiceActingAsContext {
|
|
18
22
|
userId: string;
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* app.use(createOxyRateLimit(oxy, { store: redisStore }));
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
-
export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, } from './auth';
|
|
18
|
-
export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, } from './auth';
|
|
17
|
+
export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
|
|
18
|
+
export type { OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, OxyServiceEnvironment, } from './auth';
|
|
19
19
|
export { createOxyRateLimit } from './rateLimit';
|
|
20
20
|
export type { OxyRateLimitOptions } from './rateLimit';
|
|
21
21
|
export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch';
|
|
@@ -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 declare const OXY_SERVICE_ENVIRONMENTS: readonly ["development", "staging", "production"];
|
|
17
|
+
export type OxyServiceEnvironment = (typeof OXY_SERVICE_ENVIRONMENTS)[number];
|
package/package.json
CHANGED
|
@@ -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) {
|
|
@@ -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];
|