@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.
Files changed (92) 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 +189 -0
  8. package/dist/cjs/index.js +30 -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.civic.js +611 -0
  12. package/dist/cjs/mixins/OxyServices.identity.js +291 -0
  13. package/dist/cjs/mixins/OxyServices.sso.js +28 -1
  14. package/dist/cjs/mixins/OxyServices.user.js +1 -0
  15. package/dist/cjs/mixins/index.js +6 -0
  16. package/dist/cjs/server/cors.js +20 -21
  17. package/dist/cjs/server/rateLimit.js +32 -8
  18. package/dist/cjs/utils/profileLinks.js +52 -0
  19. package/dist/cjs/utils/ssoReturn.js +1 -1
  20. package/dist/esm/.tsbuildinfo +1 -1
  21. package/dist/esm/AuthManager.js +9 -2
  22. package/dist/esm/HttpService.js +27 -9
  23. package/dist/esm/OxyServices.base.js +3 -2
  24. package/dist/esm/crypto/canonicalJson.js +104 -0
  25. package/dist/esm/crypto/keyManager.js +67 -8
  26. package/dist/esm/crypto/signatureService.js +187 -0
  27. package/dist/esm/index.js +19 -1
  28. package/dist/esm/mixins/OxyServices.assets.js +16 -1
  29. package/dist/esm/mixins/OxyServices.auth.js +190 -1
  30. package/dist/esm/mixins/OxyServices.civic.js +605 -0
  31. package/dist/esm/mixins/OxyServices.identity.js +287 -0
  32. package/dist/esm/mixins/OxyServices.sso.js +28 -1
  33. package/dist/esm/mixins/OxyServices.user.js +1 -0
  34. package/dist/esm/mixins/index.js +6 -0
  35. package/dist/esm/server/cors.js +20 -21
  36. package/dist/esm/server/rateLimit.js +32 -8
  37. package/dist/esm/utils/profileLinks.js +49 -0
  38. package/dist/esm/utils/ssoReturn.js +1 -1
  39. package/dist/types/.tsbuildinfo +1 -1
  40. package/dist/types/HttpService.d.ts +3 -0
  41. package/dist/types/OxyServices.d.ts +2 -2
  42. package/dist/types/crypto/canonicalJson.d.ts +44 -0
  43. package/dist/types/crypto/keyManager.d.ts +7 -0
  44. package/dist/types/crypto/signatureService.d.ts +112 -0
  45. package/dist/types/index.d.ts +10 -2
  46. package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
  47. package/dist/types/mixins/OxyServices.civic.d.ts +512 -0
  48. package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
  49. package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
  50. package/dist/types/mixins/index.d.ts +3 -1
  51. package/dist/types/models/interfaces.d.ts +3 -0
  52. package/dist/types/server/cors.d.ts +5 -5
  53. package/dist/types/utils/profileLinks.d.ts +36 -0
  54. package/dist/types/utils/ssoReturn.d.ts +1 -1
  55. package/package.json +2 -2
  56. package/src/AuthManager.ts +8 -2
  57. package/src/HttpService.ts +36 -8
  58. package/src/OxyServices.base.ts +3 -2
  59. package/src/OxyServices.ts +1 -1
  60. package/src/__tests__/authManager.security.test.ts +31 -0
  61. package/src/__tests__/httpServiceCsrf.test.ts +75 -0
  62. package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
  63. package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
  64. package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
  65. package/src/crypto/__tests__/signedRecord.test.ts +345 -0
  66. package/src/crypto/canonicalJson.ts +120 -0
  67. package/src/crypto/keyManager.ts +62 -12
  68. package/src/crypto/signatureService.ts +225 -0
  69. package/src/index.ts +55 -2
  70. package/src/mixins/OxyServices.assets.ts +16 -1
  71. package/src/mixins/OxyServices.auth.ts +309 -1
  72. package/src/mixins/OxyServices.civic.ts +956 -0
  73. package/src/mixins/OxyServices.identity.ts +445 -0
  74. package/src/mixins/OxyServices.sso.ts +30 -1
  75. package/src/mixins/OxyServices.user.ts +1 -0
  76. package/src/mixins/__tests__/OxyServices.civic.test.ts +1097 -0
  77. package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
  78. package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
  79. package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
  80. package/src/mixins/__tests__/serviceAuth.test.ts +19 -0
  81. package/src/mixins/__tests__/sso.test.ts +31 -0
  82. package/src/mixins/index.ts +8 -0
  83. package/src/models/interfaces.ts +3 -0
  84. package/src/server/__tests__/cors.test.ts +5 -1
  85. package/src/server/__tests__/rateLimit.test.ts +116 -0
  86. package/src/server/cors.ts +25 -20
  87. package/src/server/rateLimit.ts +39 -8
  88. package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
  89. package/src/utils/__tests__/profileLinks.test.ts +126 -0
  90. package/src/utils/__tests__/ssoReturn.test.ts +1 -1
  91. package/src/utils/profileLinks.ts +74 -0
  92. package/src/utils/ssoReturn.ts +2 -2
@@ -0,0 +1,291 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildUserDid = buildUserDid;
4
+ exports.OxyServicesIdentityMixin = OxyServicesIdentityMixin;
5
+ const keyManager_1 = require("../crypto/keyManager");
6
+ const signatureService_1 = require("../crypto/signatureService");
7
+ const mixinHelpers_1 = require("./mixinHelpers");
8
+ /**
9
+ * Registrable apex the Oxy DID method is anchored on. A user's DID is
10
+ * `did:web:<OXY_IDENTITY_APEX>:u:<userId>`, anchored on the stable account id
11
+ * (NOT the keypair).
12
+ */
13
+ const OXY_IDENTITY_APEX = 'oxy.so';
14
+ /**
15
+ * Derive a user's Oxy DID from their stable account id.
16
+ * `did:web:oxy.so:u:<userId>`.
17
+ */
18
+ function buildUserDid(userId) {
19
+ return `did:web:${OXY_IDENTITY_APEX}:u:${userId}`;
20
+ }
21
+ function OxyServicesIdentityMixin(Base) {
22
+ return class extends Base {
23
+ constructor(...args) {
24
+ super(...args);
25
+ }
26
+ /**
27
+ * Resolve the W3C DID document for any user. The API derives it on demand
28
+ * from the account's `authMethods` + `publicKey` — there is no stored
29
+ * document. Public (no auth required); short-TTL cached.
30
+ *
31
+ * @param userId - The account's Mongo `_id`. URL-encoded into the path.
32
+ */
33
+ async resolveDid(userId) {
34
+ try {
35
+ return await this.makeRequest('GET', `/u/${encodeURIComponent(userId)}/did.json`, undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
36
+ }
37
+ catch (error) {
38
+ throw this.handleError(error);
39
+ }
40
+ }
41
+ /**
42
+ * The current user's DID (`did:web:oxy.so:u:<userId>`), derived locally from
43
+ * the access token's user id. Throws if no user is authenticated.
44
+ */
45
+ getMyDid() {
46
+ const userId = this.getCurrentUserId();
47
+ if (!userId) {
48
+ throw new Error('No authenticated user — cannot derive DID.');
49
+ }
50
+ return buildUserDid(userId);
51
+ }
52
+ /** Resolve the current user's DID document. Requires an authenticated session. */
53
+ async getMyDidDocument() {
54
+ const userId = this.getCurrentUserId();
55
+ if (!userId) {
56
+ throw new Error('No authenticated user — cannot resolve DID document.');
57
+ }
58
+ return this.resolveDid(userId);
59
+ }
60
+ /**
61
+ * List the current user's linked authentication methods plus their DID.
62
+ * Each `identity` method carries a `verificationMethodId` linking it to its
63
+ * DID verification-method fragment.
64
+ */
65
+ async listAuthMethods() {
66
+ try {
67
+ return await this.makeRequest('GET', '/auth/methods', undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
68
+ }
69
+ catch (error) {
70
+ throw this.handleError(error);
71
+ }
72
+ }
73
+ /**
74
+ * Link the on-device cryptographic identity to the current account,
75
+ * upgrading it from custodial to self-sovereign. Signs a proof of private
76
+ * key ownership and posts it to `POST /auth/link`.
77
+ *
78
+ * NATIVE-ONLY: requires a stored identity (throws if `KeyManager` has no key
79
+ * or no user is authenticated). The signed payload is
80
+ * `JSON.stringify({ action: 'link_identity', userId, timestamp })` — the
81
+ * exact bytes the server reconstructs and verifies.
82
+ */
83
+ async linkIdentityKey() {
84
+ try {
85
+ const userId = this.getCurrentUserId();
86
+ if (!userId) {
87
+ throw new Error('No authenticated user — sign in before linking an identity key.');
88
+ }
89
+ const publicKey = await keyManager_1.KeyManager.getPublicKey();
90
+ if (!publicKey) {
91
+ throw new Error('No identity found on this device. Create or import an identity first.');
92
+ }
93
+ const timestamp = Date.now();
94
+ // The signed message MUST match the server's reconstruction byte-for-byte:
95
+ // JSON.stringify with this exact key order (action, userId, timestamp).
96
+ const message = JSON.stringify({ action: 'link_identity', userId, timestamp });
97
+ const signature = await signatureService_1.SignatureService.sign(message);
98
+ const result = await this.makeRequest('POST', '/auth/link', { type: 'identity', publicKey, signature, timestamp }, { cache: false });
99
+ this._invalidateIdentityCaches(userId);
100
+ return result;
101
+ }
102
+ catch (error) {
103
+ throw this.handleError(error);
104
+ }
105
+ }
106
+ /**
107
+ * Link password authentication to the current account. Adds a `password`
108
+ * auth method (does not remove existing methods).
109
+ *
110
+ * @param email - The email to associate with password auth.
111
+ * @param password - The new password (server enforces strength rules).
112
+ */
113
+ async linkPassword(email, password) {
114
+ try {
115
+ const result = await this.makeRequest('POST', '/auth/link', { type: 'password', email, password }, { cache: false });
116
+ this._invalidateIdentityCaches(this.getCurrentUserId());
117
+ return result;
118
+ }
119
+ catch (error) {
120
+ throw this.handleError(error);
121
+ }
122
+ }
123
+ /**
124
+ * Unlink an authentication method from the current account. The server
125
+ * refuses to remove the last remaining method (the account would become
126
+ * inaccessible). Unlinking `identity` downgrades the account to custodial.
127
+ *
128
+ * @param type - The auth-method type to remove.
129
+ */
130
+ async unlinkAuthMethod(type) {
131
+ try {
132
+ const result = await this.makeRequest('DELETE', `/auth/link/${encodeURIComponent(type)}`, undefined, { cache: false });
133
+ this._invalidateIdentityCaches(this.getCurrentUserId());
134
+ return result;
135
+ }
136
+ catch (error) {
137
+ throw this.handleError(error);
138
+ }
139
+ }
140
+ /**
141
+ * Sign a record with the on-device identity key, WITHOUT publishing it.
142
+ * The subject is the current user's DID. NATIVE-ONLY (requires a stored
143
+ * key). Use {@link publishRecord} to sign and store in one step.
144
+ *
145
+ * @param type - The record category.
146
+ * @param record - The arbitrary record payload to attest to.
147
+ */
148
+ async signRecord(type, record) {
149
+ const subject = this.getMyDid();
150
+ return signatureService_1.SignatureService.signRecord(type, subject, record);
151
+ }
152
+ /**
153
+ * Sign a record and publish it to the append-only record store
154
+ * (`POST /identity/records`). NATIVE-ONLY (requires a stored key).
155
+ *
156
+ * @param type - The record category.
157
+ * @param record - The arbitrary record payload to attest to.
158
+ */
159
+ async publishRecord(type, record) {
160
+ try {
161
+ const envelope = await this.signRecord(type, record);
162
+ return await this.makeRequest('POST', '/identity/records', envelope, { cache: false });
163
+ }
164
+ catch (error) {
165
+ throw this.handleError(error);
166
+ }
167
+ }
168
+ /**
169
+ * Fetch a user's most recent signed record of a given type. Public (no auth
170
+ * required); short-TTL cached.
171
+ *
172
+ * @param userId - The subject account's Mongo `_id`.
173
+ * @param type - The record category to fetch.
174
+ */
175
+ async getRecord(userId, type) {
176
+ try {
177
+ const res = await this.makeRequest('GET', `/identity/records/${encodeURIComponent(userId)}/${encodeURIComponent(type)}`, undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
178
+ return res.record;
179
+ }
180
+ catch (error) {
181
+ throw this.handleError(error);
182
+ }
183
+ }
184
+ /**
185
+ * Ask the server to verify a user's stored record: it recomputes the
186
+ * canonical signing input, checks the signature, and asserts the signing key
187
+ * is a current verification method on the subject's DID.
188
+ *
189
+ * @param userId - The subject account's Mongo `_id`.
190
+ * @param type - The record category to verify.
191
+ */
192
+ async verifyRecord(userId, type) {
193
+ try {
194
+ return await this.makeRequest('GET', `/identity/records/${encodeURIComponent(userId)}/${encodeURIComponent(type)}/verify`, undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
195
+ }
196
+ catch (error) {
197
+ throw this.handleError(error);
198
+ }
199
+ }
200
+ /**
201
+ * Download the current user's signed, open-format data-export bundle
202
+ * (`GET /users/me/export`) — the "credible exit" snapshot. Always carries an
203
+ * Oxy provenance `attestation`; carries an optional client `proof` when the
204
+ * account holds its own key.
205
+ */
206
+ async exportMyData() {
207
+ try {
208
+ return await this.makeRequest('GET', '/users/me/export', undefined, { cache: false });
209
+ }
210
+ catch (error) {
211
+ throw this.handleError(error);
212
+ }
213
+ }
214
+ /**
215
+ * Start verifying ownership of a domain. Returns the instructions: publish
216
+ * EITHER the DNS-TXT record OR the `/.well-known/oxy-domain` file, then call
217
+ * {@link verifyDomain}.
218
+ *
219
+ * @param domain - The domain to claim (e.g. `nate.com`).
220
+ */
221
+ async requestDomainVerification(domain) {
222
+ try {
223
+ return await this.makeRequest('POST', '/identity/domains', { domain }, { cache: false });
224
+ }
225
+ catch (error) {
226
+ throw this.handleError(error);
227
+ }
228
+ }
229
+ /**
230
+ * Complete domain verification: the server checks the DNS-TXT record or
231
+ * well-known file and, on success, attaches the domain to the account
232
+ * (surfaced in the DID's `alsoKnownAs` and the user's `verifiedDomains`).
233
+ *
234
+ * @param domain - The domain previously requested via
235
+ * {@link requestDomainVerification}.
236
+ */
237
+ async verifyDomain(domain) {
238
+ try {
239
+ const result = await this.makeRequest('POST', `/identity/domains/${encodeURIComponent(domain)}/verify`, undefined, { cache: false });
240
+ this._invalidateIdentityCaches(this.getCurrentUserId());
241
+ return result;
242
+ }
243
+ catch (error) {
244
+ throw this.handleError(error);
245
+ }
246
+ }
247
+ /** List the current user's verified domains. */
248
+ async listDomains() {
249
+ try {
250
+ const res = await this.makeRequest('GET', '/identity/domains', undefined, { cache: true, cacheTTL: mixinHelpers_1.CACHE_TIMES.SHORT });
251
+ return res.domains ?? [];
252
+ }
253
+ catch (error) {
254
+ throw this.handleError(error);
255
+ }
256
+ }
257
+ /**
258
+ * Remove a verified domain from the current account.
259
+ * @param domain - The verified domain to remove.
260
+ */
261
+ async removeDomain(domain) {
262
+ try {
263
+ const result = await this.makeRequest('DELETE', `/identity/domains/${encodeURIComponent(domain)}`, undefined, { cache: false });
264
+ this._invalidateIdentityCaches(this.getCurrentUserId());
265
+ return result;
266
+ }
267
+ catch (error) {
268
+ throw this.handleError(error);
269
+ }
270
+ }
271
+ /**
272
+ * Bust the cached reads that an identity mutation invalidates: the current
273
+ * user (`/users/me*`), the linked auth-methods list, the verified-domains
274
+ * list, and the user's derived DID document (which embeds auth methods +
275
+ * verified domains, so it goes stale on link/unlink/domain changes).
276
+ *
277
+ * Internal helper (leading underscore); not part of the supported public
278
+ * surface. Public rather than `private` because mixins compose into an
279
+ * exported anonymous class, where TypeScript cannot represent a private
280
+ * member in the emitted declaration file (TS4094).
281
+ */
282
+ _invalidateIdentityCaches(userId) {
283
+ this.clearCacheByPrefix('GET:/users/me');
284
+ this.clearCacheEntry('GET:/auth/methods');
285
+ this.clearCacheEntry('GET:/identity/domains');
286
+ if (userId) {
287
+ this.clearCacheEntry(`GET:/u/${encodeURIComponent(userId)}/did.json`);
288
+ }
289
+ }
290
+ };
291
+ }
@@ -27,6 +27,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
27
27
  exports.generateSsoState = generateSsoState;
28
28
  exports.OxyServicesSsoMixin = OxyServicesSsoMixin;
29
29
  const debugUtils_1 = require("../shared/utils/debugUtils");
30
+ const ssoBounce_1 = require("../utils/ssoBounce");
30
31
  const debug = (0, debugUtils_1.createDebugLogger)('SSO');
31
32
  /**
32
33
  * Generate a cryptographically secure state value for the SSO bounce.
@@ -46,6 +47,25 @@ function generateSsoState() {
46
47
  }
47
48
  throw new Error('No secure random source available for SSO state generation');
48
49
  }
50
+ /**
51
+ * Read the SSO bounce state stored for the current browser origin, if any.
52
+ *
53
+ * Returns `null` outside a browser (no `window`/`sessionStorage`) or when no
54
+ * state is stored — in which case the caller cannot (and must not) enforce a
55
+ * state match, e.g. native flows or pre-hydration callbacks that already
56
+ * validated the state before this exchange.
57
+ */
58
+ function getStoredSsoStateForCurrentOrigin() {
59
+ if (typeof window === 'undefined' || !window.location || !window.sessionStorage) {
60
+ return null;
61
+ }
62
+ try {
63
+ return window.sessionStorage.getItem((0, ssoBounce_1.ssoStateKey)(window.location.origin));
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
49
69
  function OxyServicesSsoMixin(Base) {
50
70
  return class extends Base {
51
71
  constructor(...args) {
@@ -72,12 +92,19 @@ function OxyServicesSsoMixin(Base) {
72
92
  * @param code - The opaque single-use code delivered in the SSO return
73
93
  * fragment (see {@link parseSsoReturnFragment}). The central store burns
74
94
  * it atomically on exchange.
95
+ * @param state - The state value returned alongside the code. In browsers,
96
+ * when an SSO bounce state is still stored for the current origin, this
97
+ * must match before any token-committing exchange is attempted.
75
98
  * @returns The resolved {@link SessionLoginResponse}.
76
99
  */
77
- async exchangeSsoCode(code) {
100
+ async exchangeSsoCode(code, state) {
78
101
  if (typeof code !== 'string' || code.length === 0) {
79
102
  throw this.handleError(new Error('exchangeSsoCode requires a non-empty code'));
80
103
  }
104
+ const expectedState = getStoredSsoStateForCurrentOrigin();
105
+ if (expectedState !== null && (typeof state !== 'string' || state.length === 0 || state !== expectedState)) {
106
+ throw this.handleError(new Error('SSO exchange state mismatch'));
107
+ }
81
108
  const url = `${this.getSessionBaseUrl().replace(/\/$/, '')}/sso/exchange`;
82
109
  debug.log('Exchanging SSO code for session...');
83
110
  let response;
@@ -418,6 +418,7 @@ function OxyServicesUserMixin(Base) {
418
418
  url: `/users/me/data`,
419
419
  params: { format },
420
420
  cache: false,
421
+ responseType: 'blob',
421
422
  });
422
423
  return result;
423
424
  }
@@ -15,6 +15,7 @@ const OxyServices_silent_1 = require("./OxyServices.silent");
15
15
  const OxyServices_redirect_1 = require("./OxyServices.redirect");
16
16
  const OxyServices_sso_1 = require("./OxyServices.sso");
17
17
  const OxyServices_user_1 = require("./OxyServices.user");
18
+ const OxyServices_identity_1 = require("./OxyServices.identity");
18
19
  const OxyServices_privacy_1 = require("./OxyServices.privacy");
19
20
  const OxyServices_language_1 = require("./OxyServices.language");
20
21
  const OxyServices_payment_1 = require("./OxyServices.payment");
@@ -32,6 +33,7 @@ const OxyServices_topics_1 = require("./OxyServices.topics");
32
33
  const OxyServices_managedAccounts_1 = require("./OxyServices.managedAccounts");
33
34
  const OxyServices_contacts_1 = require("./OxyServices.contacts");
34
35
  const OxyServices_appData_1 = require("./OxyServices.appData");
36
+ const OxyServices_civic_1 = require("./OxyServices.civic");
35
37
  /**
36
38
  * Mixin pipeline - applied in order from first to last.
37
39
  *
@@ -58,6 +60,8 @@ const MIXIN_PIPELINE = [
58
60
  OxyServices_sso_1.OxyServicesSsoMixin,
59
61
  // User management (requires auth)
60
62
  OxyServices_user_1.OxyServicesUserMixin,
63
+ // Self-sovereign identity (DID, signed records, auth-method ↔ VM mapping)
64
+ OxyServices_identity_1.OxyServicesIdentityMixin,
61
65
  OxyServices_privacy_1.OxyServicesPrivacyMixin,
62
66
  // Feature mixins
63
67
  OxyServices_language_1.OxyServicesLanguageMixin,
@@ -75,6 +79,8 @@ const MIXIN_PIPELINE = [
75
79
  OxyServices_managedAccounts_1.OxyServicesManagedAccountsMixin,
76
80
  OxyServices_contacts_1.OxyServicesContactsMixin,
77
81
  OxyServices_appData_1.OxyServicesAppDataMixin,
82
+ // Civic / Commons "Oxy ID" (public signed cards, Oxy ID QR payload)
83
+ OxyServices_civic_1.OxyServicesCivicMixin,
78
84
  // Utility (last, can use all above)
79
85
  OxyServices_utility_1.OxyServicesUtilityMixin,
80
86
  ];
@@ -12,10 +12,9 @@
12
12
  *
13
13
  * `createOxyCors` returns a self-contained Express middleware (no `cors`
14
14
  * package dependency) that:
15
- * - allows the Oxy apex origin family (anything under `*.${CENTRAL_IDP_APEX}`,
16
- * i.e. `oxy.so` covering `auth.oxy.so`, `api.oxy.so`, `accounts.oxy.so`,
17
- * `console.oxy.so`, `inbox.oxy.so`, the marketing site, …) reusing the
18
- * central-origin constants already in core, NOT a fresh hardcoded list,
15
+ * - allows the Oxy apex origin family over HTTPS only: the apex plus
16
+ * one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
17
+ * `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
19
18
  * - allows the caller's explicit `appOrigins`,
20
19
  * - DENIES everything else (no reflection, never a wildcard with credentials),
21
20
  * - echoes back the EXACT matched origin (so credentialed requests work) and
@@ -27,7 +26,6 @@
27
26
  Object.defineProperty(exports, "__esModule", { value: true });
28
27
  exports.createOxyCors = createOxyCors;
29
28
  const authWebUrl_1 = require("../utils/authWebUrl");
30
- const fapiAutoDetect_1 = require("../utils/fapiAutoDetect");
31
29
  /** Default HTTP methods allowed across origins. */
32
30
  const DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
33
31
  /** Default request headers a browser may send on a credentialed cross-origin call. */
@@ -41,29 +39,30 @@ const DEFAULT_ALLOWED_HEADERS = [
41
39
  ];
42
40
  /** How long (seconds) a browser may cache a successful preflight. */
43
41
  const DEFAULT_MAX_AGE_SECONDS = 86400;
42
+ const OXY_ONE_LABEL_SUBDOMAIN_PATTERN = new RegExp(`^[a-z0-9-]+\\.${authWebUrl_1.CENTRAL_IDP_APEX.replace('.', '\\.')}$`);
44
43
  /**
45
- * Whether `candidate` belongs to the Oxy apex origin family — i.e. its
46
- * registrable apex equals {@link CENTRAL_IDP_APEX} (`oxy.so`). This matches the
47
- * apex itself (`https://oxy.so`) and any subdomain (`https://auth.oxy.so`,
48
- * `https://api.oxy.so`, …) over http or https, ports allowed. Returns false on
49
- * any parse failure (fail closed).
44
+ * Whether `candidate` belongs to the built-in Oxy apex origin family. This
45
+ * intentionally mirrors the API allowlist shape: HTTPS only, no custom port,
46
+ * the apex itself (`https://oxy.so`), or exactly one lowercase subdomain label
47
+ * (`https://auth.oxy.so`, `https://api.oxy.so`, …).
48
+ *
49
+ * Arbitrary/multi-level subdomains and `http://*.oxy.so` are not implicitly
50
+ * trusted for credentialed CORS. If a service needs a non-standard development
51
+ * or tenant origin, it must opt in explicitly via `appOrigins`.
50
52
  */
51
53
  function isOxyFamilyOrigin(candidate) {
52
- let hostname;
53
- let protocol;
54
54
  try {
55
55
  const url = new URL(candidate);
56
- hostname = url.hostname.toLowerCase();
57
- protocol = url.protocol;
56
+ if (url.protocol !== 'https:' || url.port !== '')
57
+ return false;
58
+ const hostname = url.hostname;
59
+ if (hostname === authWebUrl_1.CENTRAL_IDP_APEX)
60
+ return true;
61
+ return OXY_ONE_LABEL_SUBDOMAIN_PATTERN.test(hostname);
58
62
  }
59
63
  catch {
60
64
  return false;
61
65
  }
62
- if (protocol !== 'https:' && protocol !== 'http:')
63
- return false;
64
- if (hostname === authWebUrl_1.CENTRAL_IDP_APEX)
65
- return true;
66
- return (0, fapiAutoDetect_1.registrableApex)(hostname) === authWebUrl_1.CENTRAL_IDP_APEX;
67
66
  }
68
67
  /** Normalize a raw origin string to its canonical `scheme://host[:port]` form. */
69
68
  function normalizeOrigin(raw) {
@@ -75,8 +74,8 @@ function normalizeOrigin(raw) {
75
74
  }
76
75
  }
77
76
  /**
78
- * Build the origin-matching predicate: true iff `origin` is in the Oxy apex
79
- * family OR exactly matches one of the configured app origins.
77
+ * Build the origin-matching predicate: true iff `origin` is in the built-in
78
+ * HTTPS Oxy apex family OR exactly matches one of the configured app origins.
80
79
  */
81
80
  function buildOriginAllowed(appOrigins) {
82
81
  const explicit = new Set();
@@ -25,12 +25,36 @@ function isBuiltInExempt(req) {
25
25
  function ipKeyGenerator(ip) {
26
26
  return ip.replace(/:/g, '_');
27
27
  }
28
- /** Resolve the rate-limit key: per authenticated user, else per (IPv6-safe) IP. */
29
- function resolveKey(req) {
28
+ /**
29
+ * Resolve the trusted authenticated rate-limit key.
30
+ *
31
+ * `oxy.auth({ optional: true })` preserves legacy non-session user tokens by
32
+ * decoding their JWT claims locally. Those claims are not cryptographically
33
+ * verified and therefore MUST NOT influence abuse-control buckets. Only use
34
+ * identities that came from a server-validated session or a verified service
35
+ * token/delegation.
36
+ */
37
+ function resolveTrustedAuthenticatedKey(req) {
30
38
  const userId = req.userId ?? req.user?.id ?? req.user?._id;
31
- if (userId) {
39
+ if (userId && req.sessionId) {
32
40
  return `user:${userId}`;
33
41
  }
42
+ const delegatedUserId = req.serviceActingAs?.userId;
43
+ if (delegatedUserId && req.serviceApp?.appId) {
44
+ return `user:${delegatedUserId}`;
45
+ }
46
+ const serviceAppId = req.serviceApp?.appId;
47
+ if (serviceAppId) {
48
+ return `service:${serviceAppId}`;
49
+ }
50
+ return null;
51
+ }
52
+ /** Resolve the rate-limit key: per trusted authenticated identity, else per (IPv6-safe) IP. */
53
+ function resolveKey(req) {
54
+ const authenticatedKey = resolveTrustedAuthenticatedKey(req);
55
+ if (authenticatedKey) {
56
+ return authenticatedKey;
57
+ }
34
58
  const ip = req.ip || req.socket.remoteAddress || 'unknown';
35
59
  return ipKeyGenerator(ip);
36
60
  }
@@ -47,9 +71,9 @@ function createOxyRateLimit(oxy, options = {}) {
47
71
  windowMs,
48
72
  ...(store ? { store } : {}),
49
73
  max: (req) => {
50
- const authed = req;
51
- const userId = authed.userId ?? authed.user?.id ?? authed.user?._id;
52
- return userId ? authenticatedMax : anonymousMax;
74
+ return resolveTrustedAuthenticatedKey(req)
75
+ ? authenticatedMax
76
+ : anonymousMax;
53
77
  },
54
78
  keyGenerator: (req) => resolveKey(req),
55
79
  message,
@@ -67,8 +91,8 @@ function createOxyRateLimit(oxy, options = {}) {
67
91
  resolveSession(req, res, (err) => {
68
92
  if (err) {
69
93
  // Optional auth never rejects; a token error just means "anonymous".
70
- // Swallow the error and continue to limit as anonymous.
71
- next();
94
+ // Swallow the error and continue through the anonymous limiter.
95
+ limiter(req, res, next);
72
96
  return;
73
97
  }
74
98
  limiter(req, res, next);
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeProfileLinks = normalizeProfileLinks;
4
+ function cleanUrl(value) {
5
+ if (typeof value !== 'string')
6
+ return null;
7
+ const trimmed = value.trim();
8
+ return trimmed.length > 0 ? trimmed : null;
9
+ }
10
+ /**
11
+ * Normalizes a user's profile links into a clean display shape.
12
+ *
13
+ * Pure, no side effects, no I/O.
14
+ *
15
+ * - Prefers `linksMetadata` when it is a non-empty array: maps each entry to
16
+ * `{ id, title, url }`, using `entry.id` when present and falling back to the
17
+ * entry index. Entries without a non-empty string `url` are dropped.
18
+ * - Otherwise falls back to the legacy `links` string array: maps each string to
19
+ * `{ id: <index>, url }` (no title). Empty/non-string values are dropped.
20
+ * - Returns `[]` when both are absent or empty (including when `linksMetadata`
21
+ * is present but every entry is dropped — it does NOT fall back to `links`).
22
+ *
23
+ * URLs are trimmed and blanks are filtered out. This does NOT add a scheme such
24
+ * as `https://`; prefixing is a display concern left to the caller.
25
+ */
26
+ function normalizeProfileLinks(linksMetadata, links) {
27
+ if (Array.isArray(linksMetadata) && linksMetadata.length > 0) {
28
+ const result = [];
29
+ linksMetadata.forEach((entry, index) => {
30
+ const url = cleanUrl(entry?.url);
31
+ if (!url)
32
+ return;
33
+ const id = typeof entry?.id === 'string' && entry.id.trim().length > 0
34
+ ? entry.id
35
+ : String(index);
36
+ const title = typeof entry?.title === 'string' ? entry.title : undefined;
37
+ result.push({ id, url, ...(title !== undefined ? { title } : {}) });
38
+ });
39
+ return result;
40
+ }
41
+ if (Array.isArray(links) && links.length > 0) {
42
+ const result = [];
43
+ links.forEach((value, index) => {
44
+ const url = cleanUrl(value);
45
+ if (!url)
46
+ return;
47
+ result.push({ id: String(index), url });
48
+ });
49
+ return result;
50
+ }
51
+ return [];
52
+ }
@@ -241,7 +241,7 @@ async function consumeSsoReturn(oxy, deps = {}) {
241
241
  }
242
242
  let session;
243
243
  try {
244
- session = await oxy.exchangeSsoCode(ret.code);
244
+ session = await oxy.exchangeSsoCode(ret.code, ret.state);
245
245
  }
246
246
  catch (error) {
247
247
  onExchangeError?.(error);