@oxyhq/core 3.10.1 → 3.11.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/AuthManager.js +9 -2
- package/dist/cjs/HttpService.js +27 -9
- package/dist/cjs/OxyServices.base.js +3 -2
- package/dist/cjs/crypto/canonicalJson.js +107 -0
- package/dist/cjs/crypto/keyManager.js +67 -8
- package/dist/cjs/crypto/signatureService.js +103 -0
- package/dist/cjs/index.js +15 -4
- package/dist/cjs/mixins/OxyServices.assets.js +16 -1
- package/dist/cjs/mixins/OxyServices.auth.js +190 -1
- package/dist/cjs/mixins/OxyServices.identity.js +291 -0
- package/dist/cjs/mixins/OxyServices.sso.js +28 -1
- package/dist/cjs/mixins/OxyServices.user.js +1 -0
- package/dist/cjs/mixins/index.js +3 -0
- package/dist/cjs/server/cors.js +20 -21
- package/dist/cjs/server/rateLimit.js +32 -8
- package/dist/cjs/utils/ssoReturn.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +9 -2
- package/dist/esm/HttpService.js +27 -9
- package/dist/esm/OxyServices.base.js +3 -2
- package/dist/esm/crypto/canonicalJson.js +104 -0
- package/dist/esm/crypto/keyManager.js +67 -8
- package/dist/esm/crypto/signatureService.js +102 -0
- package/dist/esm/index.js +9 -1
- package/dist/esm/mixins/OxyServices.assets.js +16 -1
- package/dist/esm/mixins/OxyServices.auth.js +190 -1
- package/dist/esm/mixins/OxyServices.identity.js +287 -0
- package/dist/esm/mixins/OxyServices.sso.js +28 -1
- package/dist/esm/mixins/OxyServices.user.js +1 -0
- package/dist/esm/mixins/index.js +3 -0
- package/dist/esm/server/cors.js +20 -21
- package/dist/esm/server/rateLimit.js +32 -8
- package/dist/esm/utils/ssoReturn.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -0
- package/dist/types/OxyServices.d.ts +2 -2
- package/dist/types/crypto/canonicalJson.d.ts +44 -0
- package/dist/types/crypto/keyManager.d.ts +7 -0
- package/dist/types/crypto/signatureService.d.ts +61 -0
- package/dist/types/index.d.ts +6 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +3 -0
- package/dist/types/server/cors.d.ts +5 -5
- package/dist/types/utils/ssoReturn.d.ts +1 -1
- package/package.json +2 -2
- package/src/AuthManager.ts +8 -2
- package/src/HttpService.ts +36 -8
- package/src/OxyServices.base.ts +3 -2
- package/src/OxyServices.ts +1 -1
- package/src/__tests__/authManager.security.test.ts +31 -0
- package/src/__tests__/httpServiceCsrf.test.ts +75 -0
- package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
- package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
- package/src/crypto/__tests__/signedRecord.test.ts +125 -0
- package/src/crypto/canonicalJson.ts +120 -0
- package/src/crypto/keyManager.ts +62 -12
- package/src/crypto/signatureService.ts +126 -0
- package/src/index.ts +27 -2
- package/src/mixins/OxyServices.assets.ts +16 -1
- package/src/mixins/OxyServices.auth.ts +309 -1
- package/src/mixins/OxyServices.identity.ts +445 -0
- package/src/mixins/OxyServices.sso.ts +30 -1
- package/src/mixins/OxyServices.user.ts +1 -0
- package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
- package/src/mixins/__tests__/sso.test.ts +31 -0
- package/src/mixins/index.ts +4 -0
- package/src/models/interfaces.ts +3 -0
- package/src/server/__tests__/cors.test.ts +5 -1
- package/src/server/__tests__/rateLimit.test.ts +116 -0
- package/src/server/cors.ts +25 -20
- package/src/server/rateLimit.ts +39 -8
- package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
- package/src/utils/__tests__/ssoReturn.test.ts +1 -1
- package/src/utils/ssoReturn.ts +2 -2
|
@@ -312,6 +312,25 @@ describe('H1: getServiceToken per-credential cache + secret verification', () =>
|
|
|
312
312
|
);
|
|
313
313
|
});
|
|
314
314
|
|
|
315
|
+
it('does not poison the cache when initial token fetch fails for an apiKey', async () => {
|
|
316
|
+
makeRequestSpy
|
|
317
|
+
.mockRejectedValueOnce(new Error('invalid service credentials'))
|
|
318
|
+
.mockResolvedValueOnce({
|
|
319
|
+
token: 'token-A',
|
|
320
|
+
expiresIn: 3600,
|
|
321
|
+
appName: 'tenant-A',
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await expect(oxy.getServiceToken('key-A', 'attacker-secret')).rejects.toThrow(
|
|
325
|
+
'invalid service credentials',
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const token = await oxy.getServiceToken('key-A', 'secret-A');
|
|
329
|
+
|
|
330
|
+
expect(token).toBe('token-A');
|
|
331
|
+
expect(makeRequestSpy).toHaveBeenCalledTimes(2);
|
|
332
|
+
});
|
|
333
|
+
|
|
315
334
|
it('refreshes the cached token when it expires (using the correct stored secret)', async () => {
|
|
316
335
|
// First token already past its buffer window.
|
|
317
336
|
makeRequestSpy.mockResolvedValueOnce({
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { OxyServices } from '../../OxyServices';
|
|
13
13
|
import { generateSsoState } from '../OxyServices.sso';
|
|
14
|
+
import { ssoStateKey } from '../../utils/ssoBounce';
|
|
14
15
|
|
|
15
16
|
interface FetchCall {
|
|
16
17
|
url: string;
|
|
@@ -118,6 +119,36 @@ describe('OxyServices.exchangeSsoCode', () => {
|
|
|
118
119
|
expect(calls).toHaveLength(0);
|
|
119
120
|
});
|
|
120
121
|
|
|
122
|
+
it('rejects a browser SSO exchange when stored state is not echoed', async () => {
|
|
123
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
124
|
+
const { calls } = mockFetchOnce(VALID_BODY);
|
|
125
|
+
const storage = new Map([[ssoStateKey('https://rp.example'), 'expected-state']]);
|
|
126
|
+
const previousWindow = (globalThis as unknown as { window?: unknown }).window;
|
|
127
|
+
|
|
128
|
+
Object.defineProperty(globalThis, 'window', {
|
|
129
|
+
configurable: true,
|
|
130
|
+
value: {
|
|
131
|
+
location: { origin: 'https://rp.example' },
|
|
132
|
+
sessionStorage: {
|
|
133
|
+
getItem: (key: string) => storage.get(key) ?? null,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await expect(oxy.exchangeSsoCode('opaque-code-123', 'attacker-state')).rejects.toThrow(
|
|
140
|
+
'SSO exchange state mismatch',
|
|
141
|
+
);
|
|
142
|
+
expect(calls).toHaveLength(0);
|
|
143
|
+
expect(oxy.hasValidToken()).toBe(false);
|
|
144
|
+
} finally {
|
|
145
|
+
Object.defineProperty(globalThis, 'window', {
|
|
146
|
+
configurable: true,
|
|
147
|
+
value: previousWindow,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
121
152
|
it('throws and does not plant a token on a non-2xx response', async () => {
|
|
122
153
|
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
123
154
|
mockFetchOnce({ error: 'invalid_code' }, false, 400);
|
package/src/mixins/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { OxyServicesSilentAuthMixin } from './OxyServices.silent';
|
|
|
12
12
|
import { OxyServicesRedirectAuthMixin } from './OxyServices.redirect';
|
|
13
13
|
import { OxyServicesSsoMixin } from './OxyServices.sso';
|
|
14
14
|
import { OxyServicesUserMixin } from './OxyServices.user';
|
|
15
|
+
import { OxyServicesIdentityMixin } from './OxyServices.identity';
|
|
15
16
|
import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
|
|
16
17
|
import { OxyServicesLanguageMixin } from './OxyServices.language';
|
|
17
18
|
import { OxyServicesPaymentMixin } from './OxyServices.payment';
|
|
@@ -46,6 +47,7 @@ type AllMixinInstances =
|
|
|
46
47
|
& InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>>
|
|
47
48
|
& InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>>
|
|
48
49
|
& InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>>
|
|
50
|
+
& InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>>
|
|
49
51
|
& InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>>
|
|
50
52
|
& InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>>
|
|
51
53
|
& InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>>
|
|
@@ -108,6 +110,8 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
108
110
|
|
|
109
111
|
// User management (requires auth)
|
|
110
112
|
OxyServicesUserMixin,
|
|
113
|
+
// Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping)
|
|
114
|
+
OxyServicesIdentityMixin,
|
|
111
115
|
OxyServicesPrivacyMixin,
|
|
112
116
|
|
|
113
117
|
// Feature mixins
|
package/src/models/interfaces.ts
CHANGED
|
@@ -34,7 +34,7 @@ function makeNext(): NextFunction & jest.Mock {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
describe('@oxyhq/core/server createOxyCors', () => {
|
|
37
|
-
it('allows the Oxy apex family (apex +
|
|
37
|
+
it('allows the HTTPS Oxy apex family (apex + one-level subdomains) and echoes the exact origin', () => {
|
|
38
38
|
const mw = createOxyCors();
|
|
39
39
|
for (const origin of [
|
|
40
40
|
'https://oxy.so',
|
|
@@ -74,6 +74,10 @@ describe('@oxyhq/core/server createOxyCors', () => {
|
|
|
74
74
|
'https://oxy.so.evil.com', // suffix attack
|
|
75
75
|
'https://notoxy.so', // different apex
|
|
76
76
|
'https://example.com',
|
|
77
|
+
'http://oxy.so',
|
|
78
|
+
'http://auth.oxy.so',
|
|
79
|
+
'https://deep.attacker-controlled.oxy.so',
|
|
80
|
+
'https://api.oxy.so:8443',
|
|
77
81
|
]) {
|
|
78
82
|
const req = makeRequest('GET', origin);
|
|
79
83
|
const res = makeResponse();
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
2
|
+
import type { OxyServices } from '../../OxyServices';
|
|
3
|
+
|
|
4
|
+
const rateLimitMock = jest.fn();
|
|
5
|
+
|
|
6
|
+
jest.mock('express-rate-limit', () => ({
|
|
7
|
+
__esModule: true,
|
|
8
|
+
default: (...args: unknown[]) => rateLimitMock(...args),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
import { createOxyRateLimit } from '../rateLimit';
|
|
12
|
+
|
|
13
|
+
interface CapturedRateLimitOptions {
|
|
14
|
+
max: (req: Request) => number;
|
|
15
|
+
keyGenerator: (req: Request) => string;
|
|
16
|
+
skip: (req: Request) => boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RateLimitTestRequest extends Request {
|
|
20
|
+
userId?: string | null;
|
|
21
|
+
user?: { id?: string; _id?: string } | null;
|
|
22
|
+
sessionId?: string | null;
|
|
23
|
+
serviceApp?: { appId?: string } | null;
|
|
24
|
+
serviceActingAs?: { userId?: string } | null;
|
|
25
|
+
observedMax?: number;
|
|
26
|
+
observedKey?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeOxy(authHandler: RequestHandler): OxyServices {
|
|
30
|
+
return {
|
|
31
|
+
auth: jest.fn(() => authHandler),
|
|
32
|
+
} as unknown as OxyServices;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeRequest(overrides: Partial<RateLimitTestRequest> = {}): RateLimitTestRequest {
|
|
36
|
+
return {
|
|
37
|
+
method: 'GET',
|
|
38
|
+
path: '/api/test',
|
|
39
|
+
ip: '203.0.113.9',
|
|
40
|
+
socket: { remoteAddress: '203.0.113.9' },
|
|
41
|
+
...overrides,
|
|
42
|
+
} as RateLimitTestRequest;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('@oxyhq/core/server rate limiter', () => {
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
rateLimitMock.mockImplementation((options: CapturedRateLimitOptions) => {
|
|
48
|
+
return (req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
|
|
49
|
+
req.observedMax = options.max(req);
|
|
50
|
+
req.observedKey = options.keyGenerator(req);
|
|
51
|
+
next();
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
jest.clearAllMocks();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('does not trust locally decoded non-session JWT identities for quota or bucket keys', () => {
|
|
61
|
+
const oxy = makeOxy((req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
|
|
62
|
+
req.userId = 'attacker-controlled-user';
|
|
63
|
+
req.user = { id: 'attacker-controlled-user' };
|
|
64
|
+
next();
|
|
65
|
+
});
|
|
66
|
+
const req = makeRequest();
|
|
67
|
+
const next = jest.fn();
|
|
68
|
+
|
|
69
|
+
createOxyRateLimit(oxy, { authenticatedMax: 5000, anonymousMax: 600 })(
|
|
70
|
+
req,
|
|
71
|
+
{} as Response,
|
|
72
|
+
next,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(req.observedMax).toBe(600);
|
|
76
|
+
expect(req.observedKey).toBe('203.0.113.9');
|
|
77
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('uses authenticated quota and per-user keys for server-validated sessions', () => {
|
|
81
|
+
const oxy = makeOxy((req: RateLimitTestRequest, _res: Response, next: NextFunction) => {
|
|
82
|
+
req.userId = 'validated-user';
|
|
83
|
+
req.user = { id: 'validated-user' };
|
|
84
|
+
req.sessionId = 'validated-session';
|
|
85
|
+
next();
|
|
86
|
+
});
|
|
87
|
+
const req = makeRequest();
|
|
88
|
+
|
|
89
|
+
createOxyRateLimit(oxy, { authenticatedMax: 5000, anonymousMax: 600 })(
|
|
90
|
+
req,
|
|
91
|
+
{} as Response,
|
|
92
|
+
jest.fn(),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(req.observedMax).toBe(5000);
|
|
96
|
+
expect(req.observedKey).toBe('user:validated-user');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('continues through the anonymous limiter if optional auth returns an error', () => {
|
|
100
|
+
const oxy = makeOxy((_req: Request, _res: Response, next: NextFunction) => {
|
|
101
|
+
next(new Error('token rejected'));
|
|
102
|
+
});
|
|
103
|
+
const req = makeRequest();
|
|
104
|
+
const next = jest.fn();
|
|
105
|
+
|
|
106
|
+
createOxyRateLimit(oxy, { authenticatedMax: 5000, anonymousMax: 600 })(
|
|
107
|
+
req,
|
|
108
|
+
{} as Response,
|
|
109
|
+
next,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(req.observedMax).toBe(600);
|
|
113
|
+
expect(req.observedKey).toBe('203.0.113.9');
|
|
114
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
115
|
+
});
|
|
116
|
+
});
|
package/src/server/cors.ts
CHANGED
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* `createOxyCors` returns a self-contained Express middleware (no `cors`
|
|
13
13
|
* package dependency) that:
|
|
14
|
-
* - allows the Oxy apex origin family
|
|
15
|
-
*
|
|
16
|
-
* `console.oxy.so`, `inbox.oxy.so`,
|
|
17
|
-
* central-origin constants already in core, NOT a fresh hardcoded list,
|
|
14
|
+
* - allows the Oxy apex origin family over HTTPS only: the apex plus
|
|
15
|
+
* one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
|
|
16
|
+
* `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
|
|
18
17
|
* - allows the caller's explicit `appOrigins`,
|
|
19
18
|
* - DENIES everything else (no reflection, never a wildcard with credentials),
|
|
20
19
|
* - echoes back the EXACT matched origin (so credentialed requests work) and
|
|
@@ -26,7 +25,6 @@
|
|
|
26
25
|
|
|
27
26
|
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
28
27
|
import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
|
|
29
|
-
import { registrableApex } from '../utils/fapiAutoDetect';
|
|
30
28
|
|
|
31
29
|
/** Default HTTP methods allowed across origins. */
|
|
32
30
|
const DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
|
|
@@ -44,11 +42,16 @@ const DEFAULT_ALLOWED_HEADERS = [
|
|
|
44
42
|
/** How long (seconds) a browser may cache a successful preflight. */
|
|
45
43
|
const DEFAULT_MAX_AGE_SECONDS = 86_400;
|
|
46
44
|
|
|
45
|
+
const OXY_ONE_LABEL_SUBDOMAIN_PATTERN = new RegExp(
|
|
46
|
+
`^[a-z0-9-]+\\.${CENTRAL_IDP_APEX.replace('.', '\\.')}$`,
|
|
47
|
+
);
|
|
48
|
+
|
|
47
49
|
export interface OxyCorsOptions {
|
|
48
50
|
/**
|
|
49
51
|
* Explicit additional allowed origins (exact-origin match, e.g.
|
|
50
52
|
* `https://app.example.com`, `http://localhost:3000`). These are allowed IN
|
|
51
|
-
* ADDITION TO the Oxy apex origin family. Each is normalized
|
|
53
|
+
* ADDITION TO the built-in HTTPS Oxy apex origin family. Each is normalized
|
|
54
|
+
* via `new URL().origin`.
|
|
52
55
|
*/
|
|
53
56
|
appOrigins?: string[];
|
|
54
57
|
/**
|
|
@@ -68,25 +71,27 @@ export interface OxyCorsOptions {
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
|
-
* Whether `candidate` belongs to the Oxy apex origin family
|
|
72
|
-
*
|
|
73
|
-
* apex itself (`https://oxy.so`)
|
|
74
|
-
* `https://api.oxy.so`, …)
|
|
75
|
-
*
|
|
74
|
+
* Whether `candidate` belongs to the built-in Oxy apex origin family. This
|
|
75
|
+
* intentionally mirrors the API allowlist shape: HTTPS only, no custom port,
|
|
76
|
+
* the apex itself (`https://oxy.so`), or exactly one lowercase subdomain label
|
|
77
|
+
* (`https://auth.oxy.so`, `https://api.oxy.so`, …).
|
|
78
|
+
*
|
|
79
|
+
* Arbitrary/multi-level subdomains and `http://*.oxy.so` are not implicitly
|
|
80
|
+
* trusted for credentialed CORS. If a service needs a non-standard development
|
|
81
|
+
* or tenant origin, it must opt in explicitly via `appOrigins`.
|
|
76
82
|
*/
|
|
77
83
|
function isOxyFamilyOrigin(candidate: string): boolean {
|
|
78
|
-
let hostname: string;
|
|
79
|
-
let protocol: string;
|
|
80
84
|
try {
|
|
81
85
|
const url = new URL(candidate);
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
if (url.protocol !== 'https:' || url.port !== '') return false;
|
|
87
|
+
|
|
88
|
+
const hostname = url.hostname;
|
|
89
|
+
if (hostname === CENTRAL_IDP_APEX) return true;
|
|
90
|
+
|
|
91
|
+
return OXY_ONE_LABEL_SUBDOMAIN_PATTERN.test(hostname);
|
|
84
92
|
} catch {
|
|
85
93
|
return false;
|
|
86
94
|
}
|
|
87
|
-
if (protocol !== 'https:' && protocol !== 'http:') return false;
|
|
88
|
-
if (hostname === CENTRAL_IDP_APEX) return true;
|
|
89
|
-
return registrableApex(hostname) === CENTRAL_IDP_APEX;
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
/** Normalize a raw origin string to its canonical `scheme://host[:port]` form. */
|
|
@@ -99,8 +104,8 @@ function normalizeOrigin(raw: string): string | null {
|
|
|
99
104
|
}
|
|
100
105
|
|
|
101
106
|
/**
|
|
102
|
-
* Build the origin-matching predicate: true iff `origin` is in the
|
|
103
|
-
* family OR exactly matches one of the configured app origins.
|
|
107
|
+
* Build the origin-matching predicate: true iff `origin` is in the built-in
|
|
108
|
+
* HTTPS Oxy apex family OR exactly matches one of the configured app origins.
|
|
104
109
|
*/
|
|
105
110
|
function buildOriginAllowed(appOrigins: string[]): (origin: string) => boolean {
|
|
106
111
|
const explicit = new Set<string>();
|
package/src/server/rateLimit.ts
CHANGED
|
@@ -41,6 +41,9 @@ import type { OxyServices } from '../OxyServices';
|
|
|
41
41
|
interface OxyAuthedRequest extends Request {
|
|
42
42
|
userId?: string | null;
|
|
43
43
|
user?: { id?: string; _id?: string } | null;
|
|
44
|
+
sessionId?: string | null;
|
|
45
|
+
serviceApp?: { appId?: string } | null;
|
|
46
|
+
serviceActingAs?: { userId?: string } | null;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
export interface OxyRateLimitOptions {
|
|
@@ -101,12 +104,40 @@ function ipKeyGenerator(ip: string): string {
|
|
|
101
104
|
return ip.replace(/:/g, '_');
|
|
102
105
|
}
|
|
103
106
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the trusted authenticated rate-limit key.
|
|
109
|
+
*
|
|
110
|
+
* `oxy.auth({ optional: true })` preserves legacy non-session user tokens by
|
|
111
|
+
* decoding their JWT claims locally. Those claims are not cryptographically
|
|
112
|
+
* verified and therefore MUST NOT influence abuse-control buckets. Only use
|
|
113
|
+
* identities that came from a server-validated session or a verified service
|
|
114
|
+
* token/delegation.
|
|
115
|
+
*/
|
|
116
|
+
function resolveTrustedAuthenticatedKey(req: OxyAuthedRequest): string | null {
|
|
106
117
|
const userId = req.userId ?? req.user?.id ?? req.user?._id;
|
|
107
|
-
if (userId) {
|
|
118
|
+
if (userId && req.sessionId) {
|
|
108
119
|
return `user:${userId}`;
|
|
109
120
|
}
|
|
121
|
+
|
|
122
|
+
const delegatedUserId = req.serviceActingAs?.userId;
|
|
123
|
+
if (delegatedUserId && req.serviceApp?.appId) {
|
|
124
|
+
return `user:${delegatedUserId}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const serviceAppId = req.serviceApp?.appId;
|
|
128
|
+
if (serviceAppId) {
|
|
129
|
+
return `service:${serviceAppId}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Resolve the rate-limit key: per trusted authenticated identity, else per (IPv6-safe) IP. */
|
|
136
|
+
function resolveKey(req: OxyAuthedRequest): string {
|
|
137
|
+
const authenticatedKey = resolveTrustedAuthenticatedKey(req);
|
|
138
|
+
if (authenticatedKey) {
|
|
139
|
+
return authenticatedKey;
|
|
140
|
+
}
|
|
110
141
|
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
|
111
142
|
return ipKeyGenerator(ip);
|
|
112
143
|
}
|
|
@@ -139,9 +170,9 @@ export function createOxyRateLimit(
|
|
|
139
170
|
windowMs,
|
|
140
171
|
...(store ? { store } : {}),
|
|
141
172
|
max: (req: Request): number => {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
173
|
+
return resolveTrustedAuthenticatedKey(req as OxyAuthedRequest)
|
|
174
|
+
? authenticatedMax
|
|
175
|
+
: anonymousMax;
|
|
145
176
|
},
|
|
146
177
|
keyGenerator: (req: Request): string => resolveKey(req as OxyAuthedRequest),
|
|
147
178
|
message,
|
|
@@ -160,8 +191,8 @@ export function createOxyRateLimit(
|
|
|
160
191
|
resolveSession(req, res, (err?: unknown) => {
|
|
161
192
|
if (err) {
|
|
162
193
|
// Optional auth never rejects; a token error just means "anonymous".
|
|
163
|
-
// Swallow the error and continue
|
|
164
|
-
next
|
|
194
|
+
// Swallow the error and continue through the anonymous limiter.
|
|
195
|
+
limiter(req, res, next);
|
|
165
196
|
return;
|
|
166
197
|
}
|
|
167
198
|
limiter(req, res, next);
|
|
@@ -218,7 +218,7 @@ describe('consumeSsoReturn', () => {
|
|
|
218
218
|
});
|
|
219
219
|
|
|
220
220
|
expect(result).toEqual(SESSION);
|
|
221
|
-
expect(oxy.exchangeSsoCode).toHaveBeenCalledWith('opaque-code');
|
|
221
|
+
expect(oxy.exchangeSsoCode).toHaveBeenCalledWith('opaque-code', 's');
|
|
222
222
|
expect(storage.map.has(ssoStateKey(ORIGIN))).toBe(false);
|
|
223
223
|
expect(storage.map.has(ssoGuardKey(ORIGIN))).toBe(false);
|
|
224
224
|
expect(storage.map.has(ssoNoSessionKey(ORIGIN))).toBe(false);
|
|
@@ -188,7 +188,7 @@ describe('consumeSsoReturn pre-hydration callback bootstrap', () => {
|
|
|
188
188
|
);
|
|
189
189
|
|
|
190
190
|
expect(session).toBe(exchangedSession);
|
|
191
|
-
expect(exchangeSsoCode).toHaveBeenCalledWith('opaque-code');
|
|
191
|
+
expect(exchangeSsoCode).toHaveBeenCalledWith('opaque-code', 'state-ok');
|
|
192
192
|
expect(replaceStateCalls).toEqual(['/', '/explore?tab=home#top']);
|
|
193
193
|
expect(dispatchPopState).toHaveBeenCalledTimes(1);
|
|
194
194
|
expect(hardRedirect).not.toHaveBeenCalled();
|
package/src/utils/ssoReturn.ts
CHANGED
|
@@ -202,7 +202,7 @@ export interface ConsumeSsoReturnDeps {
|
|
|
202
202
|
* @returns The exchanged session on success, otherwise `null`.
|
|
203
203
|
*/
|
|
204
204
|
export async function consumeSsoReturn(
|
|
205
|
-
oxy: { exchangeSsoCode: (code: string) => Promise<SessionLoginResponse> },
|
|
205
|
+
oxy: { exchangeSsoCode: (code: string, state?: string) => Promise<SessionLoginResponse> },
|
|
206
206
|
deps: ConsumeSsoReturnDeps = {},
|
|
207
207
|
): Promise<SessionLoginResponse | null> {
|
|
208
208
|
const isWeb =
|
|
@@ -344,7 +344,7 @@ export async function consumeSsoReturn(
|
|
|
344
344
|
|
|
345
345
|
let session: SessionLoginResponse | undefined;
|
|
346
346
|
try {
|
|
347
|
-
session = await oxy.exchangeSsoCode(ret.code);
|
|
347
|
+
session = await oxy.exchangeSsoCode(ret.code, ret.state);
|
|
348
348
|
} catch (error) {
|
|
349
349
|
onExchangeError?.(error);
|
|
350
350
|
markNoSession();
|