@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.
Files changed (82) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +9 -2
  3. package/dist/cjs/HttpService.js +27 -9
  4. package/dist/cjs/OxyServices.base.js +3 -2
  5. package/dist/cjs/crypto/canonicalJson.js +107 -0
  6. package/dist/cjs/crypto/keyManager.js +67 -8
  7. package/dist/cjs/crypto/signatureService.js +103 -0
  8. package/dist/cjs/index.js +15 -4
  9. package/dist/cjs/mixins/OxyServices.assets.js +16 -1
  10. package/dist/cjs/mixins/OxyServices.auth.js +190 -1
  11. package/dist/cjs/mixins/OxyServices.identity.js +291 -0
  12. package/dist/cjs/mixins/OxyServices.sso.js +28 -1
  13. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  14. package/dist/cjs/mixins/index.js +3 -0
  15. package/dist/cjs/server/cors.js +20 -21
  16. package/dist/cjs/server/rateLimit.js +32 -8
  17. package/dist/cjs/utils/ssoReturn.js +1 -1
  18. package/dist/esm/.tsbuildinfo +1 -1
  19. package/dist/esm/AuthManager.js +9 -2
  20. package/dist/esm/HttpService.js +27 -9
  21. package/dist/esm/OxyServices.base.js +3 -2
  22. package/dist/esm/crypto/canonicalJson.js +104 -0
  23. package/dist/esm/crypto/keyManager.js +67 -8
  24. package/dist/esm/crypto/signatureService.js +102 -0
  25. package/dist/esm/index.js +9 -1
  26. package/dist/esm/mixins/OxyServices.assets.js +16 -1
  27. package/dist/esm/mixins/OxyServices.auth.js +190 -1
  28. package/dist/esm/mixins/OxyServices.identity.js +287 -0
  29. package/dist/esm/mixins/OxyServices.sso.js +28 -1
  30. package/dist/esm/mixins/OxyServices.user.js +1 -0
  31. package/dist/esm/mixins/index.js +3 -0
  32. package/dist/esm/server/cors.js +20 -21
  33. package/dist/esm/server/rateLimit.js +32 -8
  34. package/dist/esm/utils/ssoReturn.js +1 -1
  35. package/dist/types/.tsbuildinfo +1 -1
  36. package/dist/types/HttpService.d.ts +3 -0
  37. package/dist/types/OxyServices.d.ts +2 -2
  38. package/dist/types/crypto/canonicalJson.d.ts +44 -0
  39. package/dist/types/crypto/keyManager.d.ts +7 -0
  40. package/dist/types/crypto/signatureService.d.ts +61 -0
  41. package/dist/types/index.d.ts +6 -2
  42. package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
  43. package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
  44. package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
  45. package/dist/types/mixins/index.d.ts +2 -1
  46. package/dist/types/models/interfaces.d.ts +3 -0
  47. package/dist/types/server/cors.d.ts +5 -5
  48. package/dist/types/utils/ssoReturn.d.ts +1 -1
  49. package/package.json +2 -2
  50. package/src/AuthManager.ts +8 -2
  51. package/src/HttpService.ts +36 -8
  52. package/src/OxyServices.base.ts +3 -2
  53. package/src/OxyServices.ts +1 -1
  54. package/src/__tests__/authManager.security.test.ts +31 -0
  55. package/src/__tests__/httpServiceCsrf.test.ts +75 -0
  56. package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
  57. package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
  58. package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
  59. package/src/crypto/__tests__/signedRecord.test.ts +125 -0
  60. package/src/crypto/canonicalJson.ts +120 -0
  61. package/src/crypto/keyManager.ts +62 -12
  62. package/src/crypto/signatureService.ts +126 -0
  63. package/src/index.ts +27 -2
  64. package/src/mixins/OxyServices.assets.ts +16 -1
  65. package/src/mixins/OxyServices.auth.ts +309 -1
  66. package/src/mixins/OxyServices.identity.ts +445 -0
  67. package/src/mixins/OxyServices.sso.ts +30 -1
  68. package/src/mixins/OxyServices.user.ts +1 -0
  69. package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
  70. package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
  71. package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
  72. package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
  73. package/src/mixins/__tests__/sso.test.ts +31 -0
  74. package/src/mixins/index.ts +4 -0
  75. package/src/models/interfaces.ts +3 -0
  76. package/src/server/__tests__/cors.test.ts +5 -1
  77. package/src/server/__tests__/rateLimit.test.ts +116 -0
  78. package/src/server/cors.ts +25 -20
  79. package/src/server/rateLimit.ts +39 -8
  80. package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
  81. package/src/utils/__tests__/ssoReturn.test.ts +1 -1
  82. 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);
@@ -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
@@ -113,6 +113,9 @@ export interface User {
113
113
  */
114
114
  name: UserNameResponse;
115
115
  bio?: string;
116
+ phone?: string;
117
+ address?: string;
118
+ birthday?: string;
116
119
  location?: string;
117
120
  website?: string;
118
121
  createdAt?: string;
@@ -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 + any subdomain) and echoes the exact origin', () => {
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
+ });
@@ -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 (anything under `*.${CENTRAL_IDP_APEX}`,
15
- * i.e. `oxy.so` covering `auth.oxy.so`, `api.oxy.so`, `accounts.oxy.so`,
16
- * `console.oxy.so`, `inbox.oxy.so`, the marketing site, …) reusing the
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 via `new URL().origin`.
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 — i.e. its
72
- * registrable apex equals {@link CENTRAL_IDP_APEX} (`oxy.so`). This matches the
73
- * apex itself (`https://oxy.so`) and any subdomain (`https://auth.oxy.so`,
74
- * `https://api.oxy.so`, …) over http or https, ports allowed. Returns false on
75
- * any parse failure (fail closed).
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
- hostname = url.hostname.toLowerCase();
83
- protocol = url.protocol;
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 Oxy apex
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>();
@@ -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
- /** Resolve the rate-limit key: per authenticated user, else per (IPv6-safe) IP. */
105
- function resolveKey(req: OxyAuthedRequest): string {
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
- const authed = req as OxyAuthedRequest;
143
- const userId = authed.userId ?? authed.user?.id ?? authed.user?._id;
144
- return userId ? authenticatedMax : anonymousMax;
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 to limit as anonymous.
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();
@@ -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();