@oxyhq/core 3.10.1 → 3.12.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 +189 -0
- package/dist/cjs/index.js +30 -4
- package/dist/cjs/mixins/OxyServices.assets.js +16 -1
- package/dist/cjs/mixins/OxyServices.auth.js +190 -1
- package/dist/cjs/mixins/OxyServices.civic.js +611 -0
- 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 +6 -0
- package/dist/cjs/server/cors.js +20 -21
- package/dist/cjs/server/rateLimit.js +32 -8
- package/dist/cjs/utils/profileLinks.js +52 -0
- 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 +187 -0
- package/dist/esm/index.js +19 -1
- package/dist/esm/mixins/OxyServices.assets.js +16 -1
- package/dist/esm/mixins/OxyServices.auth.js +190 -1
- package/dist/esm/mixins/OxyServices.civic.js +605 -0
- 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 +6 -0
- package/dist/esm/server/cors.js +20 -21
- package/dist/esm/server/rateLimit.js +32 -8
- package/dist/esm/utils/profileLinks.js +49 -0
- 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 +112 -0
- package/dist/types/index.d.ts +10 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +512 -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 +3 -1
- package/dist/types/models/interfaces.d.ts +3 -0
- package/dist/types/server/cors.d.ts +5 -5
- package/dist/types/utils/profileLinks.d.ts +36 -0
- 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 +345 -0
- package/src/crypto/canonicalJson.ts +120 -0
- package/src/crypto/keyManager.ts +62 -12
- package/src/crypto/signatureService.ts +225 -0
- package/src/index.ts +55 -2
- package/src/mixins/OxyServices.assets.ts +16 -1
- package/src/mixins/OxyServices.auth.ts +309 -1
- package/src/mixins/OxyServices.civic.ts +956 -0
- 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.civic.test.ts +1097 -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 +8 -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__/profileLinks.test.ts +126 -0
- package/src/utils/__tests__/ssoReturn.test.ts +1 -1
- package/src/utils/profileLinks.ts +74 -0
- 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';
|
|
@@ -29,6 +30,7 @@ import { OxyServicesTopicsMixin } from './OxyServices.topics';
|
|
|
29
30
|
import { OxyServicesManagedAccountsMixin } from './OxyServices.managedAccounts';
|
|
30
31
|
import { OxyServicesContactsMixin } from './OxyServices.contacts';
|
|
31
32
|
import { OxyServicesAppDataMixin } from './OxyServices.appData';
|
|
33
|
+
import { OxyServicesCivicMixin } from './OxyServices.civic';
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* Instance shape of every mixin in the pipeline, intersected. The runtime
|
|
@@ -46,6 +48,7 @@ type AllMixinInstances =
|
|
|
46
48
|
& InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>>
|
|
47
49
|
& InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>>
|
|
48
50
|
& InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>>
|
|
51
|
+
& InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>>
|
|
49
52
|
& InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>>
|
|
50
53
|
& InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>>
|
|
51
54
|
& InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>>
|
|
@@ -62,6 +65,7 @@ type AllMixinInstances =
|
|
|
62
65
|
& InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>>
|
|
63
66
|
& InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>>
|
|
64
67
|
& InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>>
|
|
68
|
+
& InstanceType<ReturnType<typeof OxyServicesCivicMixin<typeof OxyServicesBase>>>
|
|
65
69
|
& InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
66
70
|
|
|
67
71
|
/**
|
|
@@ -108,6 +112,8 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
108
112
|
|
|
109
113
|
// User management (requires auth)
|
|
110
114
|
OxyServicesUserMixin,
|
|
115
|
+
// Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping)
|
|
116
|
+
OxyServicesIdentityMixin,
|
|
111
117
|
OxyServicesPrivacyMixin,
|
|
112
118
|
|
|
113
119
|
// Feature mixins
|
|
@@ -126,6 +132,8 @@ const MIXIN_PIPELINE: MixinFunction[] = [
|
|
|
126
132
|
OxyServicesManagedAccountsMixin,
|
|
127
133
|
OxyServicesContactsMixin,
|
|
128
134
|
OxyServicesAppDataMixin,
|
|
135
|
+
// Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
|
|
136
|
+
OxyServicesCivicMixin,
|
|
129
137
|
|
|
130
138
|
// Utility (last, can use all above)
|
|
131
139
|
OxyServicesUtilityMixin,
|
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);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { normalizeProfileLinks, type ProfileLink } from '../profileLinks';
|
|
2
|
+
|
|
3
|
+
describe('normalizeProfileLinks', () => {
|
|
4
|
+
describe('linksMetadata path (preferred)', () => {
|
|
5
|
+
it('maps title + url and uses the entry id when present', () => {
|
|
6
|
+
const result = normalizeProfileLinks([
|
|
7
|
+
{ url: 'https://oxy.so', title: 'Oxy', id: 'abc' },
|
|
8
|
+
]);
|
|
9
|
+
expect(result).toEqual<ProfileLink[]>([
|
|
10
|
+
{ id: 'abc', title: 'Oxy', url: 'https://oxy.so' },
|
|
11
|
+
]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('falls back to the index when an entry has no id', () => {
|
|
15
|
+
const result = normalizeProfileLinks([
|
|
16
|
+
{ url: 'https://a.example', title: 'A' },
|
|
17
|
+
{ url: 'https://b.example', title: 'B' },
|
|
18
|
+
]);
|
|
19
|
+
expect(result).toEqual<ProfileLink[]>([
|
|
20
|
+
{ id: '0', title: 'A', url: 'https://a.example' },
|
|
21
|
+
{ id: '1', title: 'B', url: 'https://b.example' },
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('omits title when absent', () => {
|
|
26
|
+
const result = normalizeProfileLinks([{ url: 'https://no-title.example' }]);
|
|
27
|
+
expect(result).toEqual<ProfileLink[]>([
|
|
28
|
+
{ id: '0', url: 'https://no-title.example' },
|
|
29
|
+
]);
|
|
30
|
+
expect(result[0]).not.toHaveProperty('title');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('drops entries with missing or empty url and keeps stable index ids', () => {
|
|
34
|
+
const result = normalizeProfileLinks([
|
|
35
|
+
{ url: 'https://keep.example', title: 'Keep', id: 'keep' },
|
|
36
|
+
{ url: '', title: 'Empty' },
|
|
37
|
+
{ url: ' ', title: 'Whitespace' },
|
|
38
|
+
{ url: 'https://second.example', title: 'Second' },
|
|
39
|
+
]);
|
|
40
|
+
expect(result).toEqual<ProfileLink[]>([
|
|
41
|
+
{ id: 'keep', title: 'Keep', url: 'https://keep.example' },
|
|
42
|
+
// index is preserved from the source array position (3), not re-numbered
|
|
43
|
+
{ id: '3', title: 'Second', url: 'https://second.example' },
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('trims surrounding whitespace from urls', () => {
|
|
48
|
+
const result = normalizeProfileLinks([{ url: ' https://trim.example ' }]);
|
|
49
|
+
expect(result).toEqual<ProfileLink[]>([
|
|
50
|
+
{ id: '0', url: 'https://trim.example' },
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('does NOT add a scheme to bare urls', () => {
|
|
55
|
+
const result = normalizeProfileLinks([{ url: 'oxy.so', title: 'Bare' }]);
|
|
56
|
+
expect(result).toEqual<ProfileLink[]>([
|
|
57
|
+
{ id: '0', title: 'Bare', url: 'oxy.so' },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('legacy links path (no linksMetadata)', () => {
|
|
63
|
+
it('maps strings to { id, url } without titles', () => {
|
|
64
|
+
const result = normalizeProfileLinks(undefined, [
|
|
65
|
+
'https://a.example',
|
|
66
|
+
'https://b.example',
|
|
67
|
+
]);
|
|
68
|
+
expect(result).toEqual<ProfileLink[]>([
|
|
69
|
+
{ id: '0', url: 'https://a.example' },
|
|
70
|
+
{ id: '1', url: 'https://b.example' },
|
|
71
|
+
]);
|
|
72
|
+
expect(result.every((link) => !('title' in link))).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('drops empty and whitespace-only strings, preserving source index ids', () => {
|
|
76
|
+
const result = normalizeProfileLinks(undefined, [
|
|
77
|
+
'https://keep.example',
|
|
78
|
+
'',
|
|
79
|
+
' ',
|
|
80
|
+
'https://second.example',
|
|
81
|
+
]);
|
|
82
|
+
expect(result).toEqual<ProfileLink[]>([
|
|
83
|
+
{ id: '0', url: 'https://keep.example' },
|
|
84
|
+
{ id: '3', url: 'https://second.example' },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('fall-through and empty inputs', () => {
|
|
90
|
+
it('returns [] when both inputs are absent', () => {
|
|
91
|
+
expect(normalizeProfileLinks()).toEqual([]);
|
|
92
|
+
expect(normalizeProfileLinks(undefined, undefined)).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns [] when both inputs are empty arrays', () => {
|
|
96
|
+
expect(normalizeProfileLinks([], [])).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('falls through to legacy links when linksMetadata is empty', () => {
|
|
100
|
+
const result = normalizeProfileLinks([], ['https://legacy.example']);
|
|
101
|
+
expect(result).toEqual<ProfileLink[]>([
|
|
102
|
+
{ id: '0', url: 'https://legacy.example' },
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns [] (does NOT fall back to links) when linksMetadata is non-empty but every entry is dropped', () => {
|
|
107
|
+
const result = normalizeProfileLinks(
|
|
108
|
+
[{ url: '' }, { url: ' ' }],
|
|
109
|
+
['https://legacy.example'],
|
|
110
|
+
);
|
|
111
|
+
expect(result).toEqual([]);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('is pure — does not mutate its inputs', () => {
|
|
116
|
+
const linksMetadata = [{ url: 'https://oxy.so', title: 'Oxy', id: 'abc' }];
|
|
117
|
+
const links = ['https://legacy.example'];
|
|
118
|
+
const metadataSnapshot = JSON.stringify(linksMetadata);
|
|
119
|
+
const linksSnapshot = JSON.stringify(links);
|
|
120
|
+
|
|
121
|
+
normalizeProfileLinks(linksMetadata, links);
|
|
122
|
+
|
|
123
|
+
expect(JSON.stringify(linksMetadata)).toBe(metadataSnapshot);
|
|
124
|
+
expect(JSON.stringify(links)).toBe(linksSnapshot);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -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();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized profile link shape for display.
|
|
3
|
+
*
|
|
4
|
+
* `id` is a stable key for list rendering (the source entry's id when present,
|
|
5
|
+
* otherwise the source index as a string). `url` is always a non-empty string.
|
|
6
|
+
*/
|
|
7
|
+
export interface ProfileLink {
|
|
8
|
+
id: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
url: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Source shape of a single `User.linksMetadata` entry. */
|
|
14
|
+
export interface ProfileLinkMetadata {
|
|
15
|
+
url: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
image?: string;
|
|
19
|
+
id?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cleanUrl(value: unknown): string | null {
|
|
23
|
+
if (typeof value !== 'string') return null;
|
|
24
|
+
const trimmed = value.trim();
|
|
25
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalizes a user's profile links into a clean display shape.
|
|
30
|
+
*
|
|
31
|
+
* Pure, no side effects, no I/O.
|
|
32
|
+
*
|
|
33
|
+
* - Prefers `linksMetadata` when it is a non-empty array: maps each entry to
|
|
34
|
+
* `{ id, title, url }`, using `entry.id` when present and falling back to the
|
|
35
|
+
* entry index. Entries without a non-empty string `url` are dropped.
|
|
36
|
+
* - Otherwise falls back to the legacy `links` string array: maps each string to
|
|
37
|
+
* `{ id: <index>, url }` (no title). Empty/non-string values are dropped.
|
|
38
|
+
* - Returns `[]` when both are absent or empty (including when `linksMetadata`
|
|
39
|
+
* is present but every entry is dropped — it does NOT fall back to `links`).
|
|
40
|
+
*
|
|
41
|
+
* URLs are trimmed and blanks are filtered out. This does NOT add a scheme such
|
|
42
|
+
* as `https://`; prefixing is a display concern left to the caller.
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeProfileLinks(
|
|
45
|
+
linksMetadata?: ProfileLinkMetadata[],
|
|
46
|
+
links?: string[],
|
|
47
|
+
): ProfileLink[] {
|
|
48
|
+
if (Array.isArray(linksMetadata) && linksMetadata.length > 0) {
|
|
49
|
+
const result: ProfileLink[] = [];
|
|
50
|
+
linksMetadata.forEach((entry, index) => {
|
|
51
|
+
const url = cleanUrl(entry?.url);
|
|
52
|
+
if (!url) return;
|
|
53
|
+
const id =
|
|
54
|
+
typeof entry?.id === 'string' && entry.id.trim().length > 0
|
|
55
|
+
? entry.id
|
|
56
|
+
: String(index);
|
|
57
|
+
const title = typeof entry?.title === 'string' ? entry.title : undefined;
|
|
58
|
+
result.push({ id, url, ...(title !== undefined ? { title } : {}) });
|
|
59
|
+
});
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(links) && links.length > 0) {
|
|
64
|
+
const result: ProfileLink[] = [];
|
|
65
|
+
links.forEach((value, index) => {
|
|
66
|
+
const url = cleanUrl(value);
|
|
67
|
+
if (!url) return;
|
|
68
|
+
result.push({ id: String(index), url });
|
|
69
|
+
});
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [];
|
|
74
|
+
}
|
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();
|