@oxyhq/core 1.11.12 → 1.11.14

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 (130) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +3 -1
  3. package/dist/cjs/HttpService.js +214 -33
  4. package/dist/cjs/OxyServices.base.js +9 -0
  5. package/dist/cjs/OxyServices.js +8 -3
  6. package/dist/cjs/crypto/index.js +3 -1
  7. package/dist/cjs/crypto/keyManager.js +476 -172
  8. package/dist/cjs/crypto/polyfill.js +14 -65
  9. package/dist/cjs/crypto/recoveryPhrase.js +30 -11
  10. package/dist/cjs/crypto/signatureService.js +25 -60
  11. package/dist/cjs/i18n/locales/en-US.json +46 -1
  12. package/dist/cjs/i18n/locales/es-ES.json +46 -1
  13. package/dist/cjs/i18n/locales/locales/en-US.json +46 -1
  14. package/dist/cjs/i18n/locales/locales/es-ES.json +46 -1
  15. package/dist/cjs/index.js +10 -2
  16. package/dist/cjs/mixins/OxyServices.assets.js +9 -4
  17. package/dist/cjs/mixins/OxyServices.auth.js +147 -14
  18. package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
  19. package/dist/cjs/mixins/OxyServices.features.js +0 -11
  20. package/dist/cjs/mixins/OxyServices.fedcm.js +4 -3
  21. package/dist/cjs/mixins/OxyServices.language.js +5 -36
  22. package/dist/cjs/mixins/OxyServices.redirect.js +6 -2
  23. package/dist/cjs/mixins/OxyServices.security.js +13 -2
  24. package/dist/cjs/mixins/OxyServices.user.js +59 -38
  25. package/dist/cjs/mixins/OxyServices.utility.js +416 -110
  26. package/dist/cjs/mixins/index.js +11 -3
  27. package/dist/cjs/utils/accountUtils.js +71 -2
  28. package/dist/cjs/utils/deviceManager.js +5 -36
  29. package/dist/cjs/utils/languageUtils.js +22 -0
  30. package/dist/cjs/utils/platformCrypto.js +165 -0
  31. package/dist/cjs/utils/platformCrypto.native.js +123 -0
  32. package/dist/esm/.tsbuildinfo +1 -1
  33. package/dist/esm/CrossDomainAuth.js +3 -1
  34. package/dist/esm/HttpService.js +215 -34
  35. package/dist/esm/OxyServices.base.js +9 -0
  36. package/dist/esm/OxyServices.js +8 -3
  37. package/dist/esm/crypto/index.js +1 -1
  38. package/dist/esm/crypto/keyManager.js +473 -138
  39. package/dist/esm/crypto/polyfill.js +14 -32
  40. package/dist/esm/crypto/recoveryPhrase.js +30 -11
  41. package/dist/esm/crypto/signatureService.js +25 -27
  42. package/dist/esm/i18n/locales/en-US.json +46 -1
  43. package/dist/esm/i18n/locales/es-ES.json +46 -1
  44. package/dist/esm/i18n/locales/locales/en-US.json +46 -1
  45. package/dist/esm/i18n/locales/locales/es-ES.json +46 -1
  46. package/dist/esm/index.js +4 -3
  47. package/dist/esm/mixins/OxyServices.assets.js +9 -4
  48. package/dist/esm/mixins/OxyServices.auth.js +145 -14
  49. package/dist/esm/mixins/OxyServices.contacts.js +47 -0
  50. package/dist/esm/mixins/OxyServices.features.js +0 -11
  51. package/dist/esm/mixins/OxyServices.fedcm.js +4 -3
  52. package/dist/esm/mixins/OxyServices.language.js +5 -3
  53. package/dist/esm/mixins/OxyServices.redirect.js +6 -2
  54. package/dist/esm/mixins/OxyServices.security.js +13 -2
  55. package/dist/esm/mixins/OxyServices.user.js +59 -38
  56. package/dist/esm/mixins/OxyServices.utility.js +416 -77
  57. package/dist/esm/mixins/index.js +11 -3
  58. package/dist/esm/utils/accountUtils.js +67 -1
  59. package/dist/esm/utils/deviceManager.js +5 -3
  60. package/dist/esm/utils/languageUtils.js +21 -0
  61. package/dist/esm/utils/platformCrypto.js +125 -0
  62. package/dist/esm/utils/platformCrypto.native.js +80 -0
  63. package/dist/types/.tsbuildinfo +1 -1
  64. package/dist/types/HttpService.d.ts +47 -3
  65. package/dist/types/OxyServices.base.d.ts +7 -0
  66. package/dist/types/OxyServices.d.ts +50 -7
  67. package/dist/types/crypto/index.d.ts +1 -1
  68. package/dist/types/crypto/keyManager.d.ts +110 -9
  69. package/dist/types/crypto/polyfill.d.ts +3 -1
  70. package/dist/types/crypto/recoveryPhrase.d.ts +31 -7
  71. package/dist/types/crypto/signatureService.d.ts +4 -0
  72. package/dist/types/index.d.ts +7 -5
  73. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
  74. package/dist/types/mixins/OxyServices.assets.d.ts +6 -10
  75. package/dist/types/mixins/OxyServices.auth.d.ts +82 -5
  76. package/dist/types/mixins/OxyServices.contacts.d.ts +99 -0
  77. package/dist/types/mixins/OxyServices.developer.d.ts +1 -0
  78. package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
  79. package/dist/types/mixins/OxyServices.features.d.ts +2 -7
  80. package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
  81. package/dist/types/mixins/OxyServices.karma.d.ts +1 -0
  82. package/dist/types/mixins/OxyServices.language.d.ts +1 -0
  83. package/dist/types/mixins/OxyServices.location.d.ts +1 -0
  84. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -0
  85. package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
  86. package/dist/types/mixins/OxyServices.popup.d.ts +1 -0
  87. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
  88. package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
  89. package/dist/types/mixins/OxyServices.security.d.ts +1 -0
  90. package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
  91. package/dist/types/mixins/OxyServices.user.d.ts +28 -11
  92. package/dist/types/mixins/OxyServices.utility.d.ts +145 -10
  93. package/dist/types/mixins/index.d.ts +52 -4
  94. package/dist/types/models/interfaces.d.ts +62 -3
  95. package/dist/types/utils/accountUtils.d.ts +41 -1
  96. package/dist/types/utils/languageUtils.d.ts +1 -0
  97. package/dist/types/utils/platformCrypto.d.ts +87 -0
  98. package/dist/types/utils/platformCrypto.native.d.ts +54 -0
  99. package/package.json +45 -2
  100. package/src/CrossDomainAuth.ts +12 -10
  101. package/src/HttpService.ts +251 -40
  102. package/src/OxyServices.base.ts +10 -0
  103. package/src/OxyServices.ts +26 -7
  104. package/src/crypto/__tests__/keyManager.test.ts +336 -0
  105. package/src/crypto/index.ts +6 -1
  106. package/src/crypto/keyManager.ts +529 -151
  107. package/src/crypto/polyfill.ts +14 -34
  108. package/src/crypto/recoveryPhrase.ts +56 -17
  109. package/src/crypto/signatureService.ts +25 -30
  110. package/src/i18n/locales/en-US.json +46 -1
  111. package/src/i18n/locales/es-ES.json +46 -1
  112. package/src/index.ts +19 -4
  113. package/src/mixins/OxyServices.assets.ts +15 -11
  114. package/src/mixins/OxyServices.auth.ts +175 -15
  115. package/src/mixins/OxyServices.contacts.ts +73 -0
  116. package/src/mixins/OxyServices.features.ts +2 -12
  117. package/src/mixins/OxyServices.fedcm.ts +4 -3
  118. package/src/mixins/OxyServices.language.ts +6 -4
  119. package/src/mixins/OxyServices.redirect.ts +6 -2
  120. package/src/mixins/OxyServices.security.ts +18 -8
  121. package/src/mixins/OxyServices.user.ts +72 -49
  122. package/src/mixins/OxyServices.utility.ts +562 -89
  123. package/src/mixins/__tests__/serviceAuth.test.ts +623 -0
  124. package/src/mixins/index.ts +58 -7
  125. package/src/models/interfaces.ts +65 -3
  126. package/src/utils/accountUtils.ts +82 -2
  127. package/src/utils/deviceManager.ts +7 -4
  128. package/src/utils/languageUtils.ts +23 -2
  129. package/src/utils/platformCrypto.native.ts +101 -0
  130. package/src/utils/platformCrypto.ts +145 -0
@@ -1,37 +1,4 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.OxyServicesUtilityMixin = OxyServicesUtilityMixin;
37
4
  /**
@@ -41,13 +8,58 @@ exports.OxyServicesUtilityMixin = OxyServicesUtilityMixin;
41
8
  * and Express.js authentication middleware
42
9
  */
43
10
  const jwt_decode_1 = require("jwt-decode");
11
+ const platformCrypto_1 = require("../utils/platformCrypto");
12
+ const loggerUtils_1 = require("../utils/loggerUtils");
44
13
  const mixinHelpers_1 = require("./mixinHelpers");
14
+ /**
15
+ * Expected JWT audience for tokens issued by the Oxy auth service.
16
+ */
17
+ const OXY_JWT_AUDIENCE = 'oxy-api';
18
+ /**
19
+ * Expected JWT issuer for tokens issued by the Oxy auth service.
20
+ */
21
+ const OXY_JWT_ISSUER = 'oxy-auth';
22
+ /**
23
+ * Sentinel error classes for service-token verification. Using classes (not
24
+ * message strings) makes the catch site below safe to extend: a new failure
25
+ * mode added later cannot silently fall through to the generic 500 branch.
26
+ */
27
+ class ServiceTokenStructureError extends Error {
28
+ constructor(message = 'Service token has malformed structure') {
29
+ super(message);
30
+ this.name = 'ServiceTokenStructureError';
31
+ }
32
+ }
33
+ class ServiceTokenSignatureError extends Error {
34
+ constructor(message = 'Service token signature is invalid') {
35
+ super(message);
36
+ this.name = 'ServiceTokenSignatureError';
37
+ }
38
+ }
39
+ class ServiceTokenClaimError extends Error {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = 'ServiceTokenClaimError';
43
+ }
44
+ }
45
45
  function OxyServicesUtilityMixin(Base) {
46
46
  return class extends Base {
47
+ // TypeScript's mixin pattern requires `(...args: any[])` here — the
48
+ // constructor signature is a structural shape check the compiler enforces.
49
+ // Matches every other mixin in this package; do not change without a
50
+ // monorepo-wide refactor of the mixin pipeline.
47
51
  constructor(...args) {
48
52
  super(...args);
49
53
  /** @internal In-memory cache for acting-as verification results (TTL: 5 min) */
50
54
  this._actingAsCache = new Map();
55
+ /**
56
+ * In-memory cache for service-acting-as verification.
57
+ * Negative results are cached for 1min to avoid hammering the verify
58
+ * endpoint when a service is misconfigured; positive grants are cached
59
+ * for 5min to amortize the round-trip without holding stale grants too long.
60
+ * @internal
61
+ */
62
+ this._serviceActingAsCache = new Map();
51
63
  }
52
64
  /**
53
65
  * Verify that a user is authorized to act as a managed account.
@@ -73,7 +85,13 @@ function OxyServicesUtilityMixin(Base) {
73
85
  });
74
86
  return result && result.authorized ? result : null;
75
87
  }
76
- catch {
88
+ catch (error) {
89
+ loggerUtils_1.logger.warn('[oxy.auth] verifyActingAs lookup failed — caching negative result', {
90
+ component: 'auth',
91
+ method: 'verifyActingAs',
92
+ userId,
93
+ accountId,
94
+ }, error);
77
95
  // Cache negative result for 1 minute to avoid hammering on transient errors
78
96
  this._actingAsCache.set(cacheKey, {
79
97
  result: null,
@@ -82,6 +100,53 @@ function OxyServicesUtilityMixin(Base) {
82
100
  return null;
83
101
  }
84
102
  }
103
+ /**
104
+ * Verify that a service app holds an active delegation grant authorising
105
+ * it to act on behalf of `userId`. Returns the grant (with allowed scopes)
106
+ * on success or `null` if no valid grant exists. Negative answers are
107
+ * cached briefly to protect the verify endpoint from misconfigured callers.
108
+ *
109
+ * Implemented as a per-instance Map keyed by `appId:userId`. Cached
110
+ * positive grants live for 5 minutes (acceptable staleness window for an
111
+ * impersonation grant); revocations propagate within that window.
112
+ *
113
+ * @internal Used by the auth() middleware — not part of the public API
114
+ */
115
+ async verifyServiceActingAs(appId, userId) {
116
+ const cacheKey = `${appId}:${userId}`;
117
+ const now = Date.now();
118
+ const cached = this._serviceActingAsCache.get(cacheKey);
119
+ if (cached && cached.expiresAt > now) {
120
+ return cached.result;
121
+ }
122
+ try {
123
+ const result = await this.makeRequest('GET', '/internal/service-acting-as/verify', { appId, userId }, { cache: false, retry: false, timeout: 5000 });
124
+ const authorized = Boolean(result && result.authorized);
125
+ const verified = authorized
126
+ ? { authorized: true, scopes: Array.isArray(result.scopes) ? result.scopes : [] }
127
+ : null;
128
+ this._serviceActingAsCache.set(cacheKey, {
129
+ result: verified,
130
+ expiresAt: now + 5 * 60 * 1000,
131
+ });
132
+ return verified;
133
+ }
134
+ catch (error) {
135
+ loggerUtils_1.logger.warn('[oxy.auth] verifyServiceActingAs lookup failed — caching negative result', {
136
+ component: 'auth',
137
+ method: 'verifyServiceActingAs',
138
+ appId,
139
+ userId,
140
+ }, error);
141
+ // Negative cache prevents a runaway loop if the verify endpoint is
142
+ // down, while still letting a real grant become visible within 60s.
143
+ this._serviceActingAsCache.set(cacheKey, {
144
+ result: null,
145
+ expiresAt: now + 1 * 60 * 1000,
146
+ });
147
+ return null;
148
+ }
149
+ }
85
150
  /**
86
151
  * Fetch link metadata
87
152
  */
@@ -109,9 +174,17 @@ function OxyServicesUtilityMixin(Base) {
109
174
  * - Security comes from API-based session validation (`validateSession()`)
110
175
  * which checks the session server-side on every request
111
176
  * - Service tokens (type: 'service') DO use cryptographic HMAC verification
112
- * via the `jwtSecret` option, since they are stateless
177
+ * via the `jwtSecret` option, since they are stateless. Service tokens
178
+ * are additionally checked for `aud`, `iss`, and `type` claims to prevent
179
+ * cross-token-type confusion attacks.
113
180
  * - The backend's own `authMiddleware` uses `jwt.verify()` because it has
114
- * direct access to `ACCESS_TOKEN_SECRET`
181
+ * direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
182
+ *
183
+ * **Service-token delegation (X-Oxy-User-Id):**
184
+ * When a service token is accompanied by `X-Oxy-User-Id`, the SDK calls
185
+ * `verifyServiceActingAs(appId, userId)` to confirm an explicit delegation
186
+ * grant exists before attaching `req.userId`. A missing/expired grant
187
+ * results in a 403 — there is no fail-open path.
115
188
  *
116
189
  * @example
117
190
  * ```typescript
@@ -120,7 +193,7 @@ function OxyServicesUtilityMixin(Base) {
120
193
  * const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
121
194
  *
122
195
  * // Protect all routes under /protected
123
- * app.use('/protected', oxy.auth());
196
+ * app.use('/protected', oxy.auth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
124
197
  *
125
198
  * // Access user in route handler
126
199
  * app.get('/protected/me', (req, res) => {
@@ -132,14 +205,18 @@ function OxyServicesUtilityMixin(Base) {
132
205
  *
133
206
  * // Optional auth - attach user if present, don't block if absent
134
207
  * app.use('/public', oxy.auth({ optional: true }));
208
+ *
209
+ * // Require a specific scope on a service-token-protected route
210
+ * app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }), oxy.requireScope('files:write'));
135
211
  * ```
136
212
  *
137
213
  * @param options Optional configuration
138
214
  * @returns Express middleware function
139
215
  */
140
216
  auth(options = {}) {
141
- const { debug = false, onError, loadUser = false, optional = false, jwtSecret } = options;
142
- // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
217
+ const { debug = false, onError, loadUser = false, optional = false, jwtSecret, expectedIssuer = OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
218
+ // Cross-mixin method access: typed as a structural subset of the
219
+ // composed OxyServices we know we have at runtime.
143
220
  const oxyInstance = this;
144
221
  // Return an async middleware function
145
222
  return async (req, res, next) => {
@@ -149,9 +226,12 @@ function OxyServicesUtilityMixin(Base) {
149
226
  // the managed account, preserving the original user for audit trails.
150
227
  const processActingAs = async () => {
151
228
  const actingAsUserId = req.headers['x-acting-as'];
152
- if (!actingAsUserId)
229
+ if (!actingAsUserId || typeof actingAsUserId !== 'string')
153
230
  return true; // No header, proceed normally
154
- const verification = await oxyInstance.verifyActingAs(req.userId, actingAsUserId);
231
+ const currentUserId = req.userId;
232
+ if (!currentUserId)
233
+ return true; // No authenticated user yet — nothing to swap
234
+ const verification = await oxyInstance.verifyActingAs(currentUserId, actingAsUserId);
155
235
  if (!verification) {
156
236
  const error = {
157
237
  error: 'ACTING_AS_UNAUTHORIZED',
@@ -168,23 +248,25 @@ function OxyServicesUtilityMixin(Base) {
168
248
  return false;
169
249
  }
170
250
  // Preserve original user for audit trails
171
- req.originalUser = { id: req.userId, ...req.user };
251
+ req.originalUser = { id: currentUserId, ...(req.user ?? {}) };
172
252
  req.actingAs = { userId: actingAsUserId, role: verification.role };
173
253
  // Swap user identity to the managed account
174
254
  req.userId = actingAsUserId;
175
- req.user = { id: actingAsUserId };
176
- // Also set _id for routes that use Pattern B (req.user._id)
177
- if (req.user) {
178
- req.user._id = actingAsUserId;
179
- }
255
+ req.user = { id: actingAsUserId, _id: actingAsUserId };
180
256
  if (debug) {
181
- console.log(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${req.originalUser.id}`);
257
+ loggerUtils_1.logger.debug(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${currentUserId}`, {
258
+ component: 'auth',
259
+ method: 'auth.processActingAs',
260
+ });
182
261
  }
183
262
  return true;
184
263
  };
185
264
  try {
186
- // Extract token from Authorization header or query params
187
- const authHeader = req.headers['authorization'];
265
+ // Extract token from Authorization header or query params.
266
+ // Node/Express normalizes `Authorization` to a string; we guard
267
+ // against the (legal but unusual) string[] case anyway.
268
+ const rawAuthHeader = req.headers.authorization;
269
+ const authHeader = Array.isArray(rawAuthHeader) ? rawAuthHeader[0] : rawAuthHeader;
188
270
  let token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
189
271
  // Fallback to query params (useful for WebSocket upgrades)
190
272
  if (!token) {
@@ -195,7 +277,10 @@ function OxyServicesUtilityMixin(Base) {
195
277
  token = q.access_token;
196
278
  }
197
279
  if (debug) {
198
- console.log(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`);
280
+ loggerUtils_1.logger.debug(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`, {
281
+ component: 'auth',
282
+ method: 'auth',
283
+ });
199
284
  }
200
285
  if (!token) {
201
286
  if (optional) {
@@ -219,6 +304,12 @@ function OxyServicesUtilityMixin(Base) {
219
304
  decoded = (0, jwt_decode_1.jwtDecode)(token);
220
305
  }
221
306
  catch (decodeError) {
307
+ if (debug) {
308
+ loggerUtils_1.logger.debug('[oxy.auth] Token decode failed', {
309
+ component: 'auth',
310
+ method: 'auth',
311
+ }, decodeError);
312
+ }
222
313
  if (optional) {
223
314
  req.userId = null;
224
315
  req.user = null;
@@ -254,49 +345,72 @@ function OxyServicesUtilityMixin(Base) {
254
345
  return onError(error);
255
346
  return res.status(403).json(error);
256
347
  }
257
- // Verify JWT signature (not just decode)
348
+ // Verify JWT signature, then audience / issuer / type / appId claims.
349
+ //
350
+ // Signature verification uses a manual HMAC-SHA256 compare because
351
+ // this file ships into RN/web bundles where `jsonwebtoken` is
352
+ // unavailable. The middleware only ever runs on Node hosts (see
353
+ // platformCrypto's doc-comment), and `loadNodeCrypto` is per-
354
+ // platform: the RN variant throws so Metro never bundles a Node
355
+ // built-in reference.
258
356
  try {
259
- const { createHmac } = await Promise.resolve().then(() => __importStar(require('crypto')));
260
- const [headerB64, payloadB64, signatureB64] = token.split('.');
261
- if (!headerB64 || !payloadB64 || !signatureB64) {
262
- throw new Error('Invalid token structure');
263
- }
264
- const expectedSig = createHmac('sha256', jwtSecret)
265
- .update(`${headerB64}.${payloadB64}`)
266
- .digest('base64')
267
- .replace(/\+/g, '-')
268
- .replace(/\//g, '_')
269
- .replace(/=/g, '');
270
- // Timing-safe comparison
271
- const sigBuf = Buffer.from(signatureB64);
272
- const expectedBuf = Buffer.from(expectedSig);
273
- const { timingSafeEqual } = await Promise.resolve().then(() => __importStar(require('crypto')));
274
- if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
275
- throw new Error('Invalid signature');
276
- }
357
+ await verifyServiceTokenSignature(token, jwtSecret);
358
+ verifyServiceTokenClaims(decoded, {
359
+ audience: expectedAudience,
360
+ issuer: expectedIssuer,
361
+ });
277
362
  }
278
363
  catch (verifyError) {
279
- const isSignatureError = verifyError instanceof Error &&
280
- (verifyError.message === 'Invalid signature' || verifyError.message === 'Invalid token structure');
281
- if (!isSignatureError) {
282
- console.error('[oxy.auth] Unexpected error during service token verification:', verifyError);
283
- const error = { error: 'AUTH_INTERNAL_ERROR', message: 'Internal authentication error', code: 'AUTH_INTERNAL_ERROR', status: 500 };
364
+ // Structure + signature + claim errors all map to 401. Anything
365
+ // else (e.g. Node crypto failing to load on a misconfigured host)
366
+ // genuinely IS a 500.
367
+ if (verifyError instanceof ServiceTokenStructureError ||
368
+ verifyError instanceof ServiceTokenSignatureError ||
369
+ verifyError instanceof ServiceTokenClaimError) {
370
+ if (debug) {
371
+ loggerUtils_1.logger.debug('[oxy.auth] Service token rejected', {
372
+ component: 'auth',
373
+ method: 'auth.serviceToken',
374
+ reason: verifyError.name,
375
+ detail: verifyError.message,
376
+ });
377
+ }
378
+ if (optional) {
379
+ req.userId = null;
380
+ req.user = null;
381
+ return next();
382
+ }
383
+ const code = verifyError instanceof ServiceTokenSignatureError
384
+ ? 'INVALID_SERVICE_TOKEN'
385
+ : verifyError instanceof ServiceTokenStructureError
386
+ ? 'INVALID_SERVICE_TOKEN'
387
+ : 'INVALID_SERVICE_TOKEN_CLAIMS';
388
+ const error = {
389
+ error: code,
390
+ message: verifyError.message,
391
+ code,
392
+ status: 401,
393
+ };
284
394
  if (onError)
285
395
  return onError(error);
286
- return res.status(500).json(error);
287
- }
288
- if (optional) {
289
- req.userId = null;
290
- req.user = null;
291
- return next();
396
+ return res.status(401).json(error);
292
397
  }
293
- const error = { error: 'INVALID_SERVICE_TOKEN', message: 'Invalid service token signature', code: 'INVALID_SERVICE_TOKEN', status: 401 };
398
+ loggerUtils_1.logger.error('[oxy.auth] Unexpected error during service token verification', verifyError, {
399
+ component: 'auth',
400
+ method: 'auth.serviceToken',
401
+ });
402
+ const error = {
403
+ error: 'AUTH_INTERNAL_ERROR',
404
+ message: 'Internal authentication error',
405
+ code: 'AUTH_INTERNAL_ERROR',
406
+ status: 500,
407
+ };
294
408
  if (onError)
295
409
  return onError(error);
296
- return res.status(401).json(error);
410
+ return res.status(500).json(error);
297
411
  }
298
- // Check expiration
299
- if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
412
+ // Check expiration — reject tokens at exact expiry second (use <=)
413
+ if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
300
414
  if (optional) {
301
415
  req.userId = null;
302
416
  req.user = null;
@@ -308,7 +422,8 @@ function OxyServicesUtilityMixin(Base) {
308
422
  return res.status(401).json(error);
309
423
  }
310
424
  // Validate required service token fields
311
- if (!decoded.appId) {
425
+ const appId = decoded.appId;
426
+ if (!appId) {
312
427
  if (optional) {
313
428
  req.userId = null;
314
429
  req.user = null;
@@ -320,16 +435,52 @@ function OxyServicesUtilityMixin(Base) {
320
435
  return res.status(401).json(error);
321
436
  }
322
437
  // Read delegated user ID from header
323
- const oxyUserId = req.headers['x-oxy-user-id'];
324
- req.userId = oxyUserId || null;
325
- req.user = oxyUserId ? { id: oxyUserId } : null;
438
+ const oxyUserIdRaw = req.headers['x-oxy-user-id'];
439
+ const oxyUserId = typeof oxyUserIdRaw === 'string' && oxyUserIdRaw.length > 0 ? oxyUserIdRaw : null;
440
+ // C3: a service may only act as a user when an explicit
441
+ // ServiceActingAs grant exists for that (appId, userId) pair.
442
+ // Without the grant we MUST refuse — silently attaching
443
+ // `req.userId = oxyUserId` would let any service impersonate
444
+ // any user simply by setting the header.
445
+ if (oxyUserId) {
446
+ const grant = await oxyInstance.verifyServiceActingAs(appId, oxyUserId);
447
+ if (!grant || !grant.authorized) {
448
+ loggerUtils_1.logger.warn('[oxy.auth] Service token rejected — no delegation grant', {
449
+ component: 'auth',
450
+ method: 'auth.serviceToken',
451
+ appId,
452
+ attemptedUserId: oxyUserId,
453
+ });
454
+ const error = {
455
+ error: 'SERVICE_ACTING_AS_UNAUTHORIZED',
456
+ message: 'Service not authorized to act as this user',
457
+ code: 'SERVICE_ACTING_AS_UNAUTHORIZED',
458
+ status: 403,
459
+ };
460
+ if (onError)
461
+ return onError(error);
462
+ return res.status(403).json(error);
463
+ }
464
+ req.userId = oxyUserId;
465
+ req.user = { id: oxyUserId };
466
+ req.serviceActingAs = { userId: oxyUserId, scopes: grant.scopes };
467
+ }
468
+ else {
469
+ // No X-Oxy-User-Id means the service is acting as itself.
470
+ req.userId = null;
471
+ req.user = null;
472
+ }
326
473
  req.accessToken = token;
327
474
  req.serviceApp = {
328
- appId: decoded.appId || '',
475
+ appId,
329
476
  appName: decoded.appName || 'unknown',
477
+ scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
330
478
  };
331
479
  if (debug) {
332
- console.log(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`);
480
+ loggerUtils_1.logger.debug(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`, {
481
+ component: 'auth',
482
+ method: 'auth.serviceToken',
483
+ });
333
484
  }
334
485
  return next();
335
486
  }
@@ -351,7 +502,8 @@ function OxyServicesUtilityMixin(Base) {
351
502
  return res.status(401).json(error);
352
503
  }
353
504
  // Check token expiration locally first (fast path)
354
- if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
505
+ // Reject tokens at exact expiry second (use <=)
506
+ if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
355
507
  if (optional) {
356
508
  req.userId = null;
357
509
  req.user = null;
@@ -402,7 +554,10 @@ function OxyServicesUtilityMixin(Base) {
402
554
  req.user = { id: userId };
403
555
  }
404
556
  if (debug) {
405
- console.log(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`);
557
+ loggerUtils_1.logger.debug(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`, {
558
+ component: 'auth',
559
+ method: 'auth',
560
+ });
406
561
  }
407
562
  // Process X-Acting-As header before proceeding
408
563
  if (await processActingAs())
@@ -411,7 +566,10 @@ function OxyServicesUtilityMixin(Base) {
411
566
  }
412
567
  catch (validationError) {
413
568
  if (debug) {
414
- console.log(`[oxy.auth] Session validation failed:`, validationError);
569
+ loggerUtils_1.logger.debug('[oxy.auth] Session validation failed', {
570
+ component: 'auth',
571
+ method: 'auth',
572
+ }, validationError);
415
573
  }
416
574
  if (optional) {
417
575
  req.userId = null;
@@ -451,25 +609,44 @@ function OxyServicesUtilityMixin(Base) {
451
609
  req.user = fullUser;
452
610
  }
453
611
  }
454
- catch {
455
- // Failed to load user, continue with basic user object
612
+ catch (loadUserError) {
613
+ // Loading the full user is best-effort here; the basic { id }
614
+ // object is already attached. Log so misconfigured deployments
615
+ // can be diagnosed instead of silently failing.
616
+ loggerUtils_1.logger.warn('[oxy.auth] loadUser fallback — could not fetch full profile', {
617
+ component: 'auth',
618
+ method: 'auth.loadUser',
619
+ userId,
620
+ }, loadUserError);
456
621
  }
457
622
  }
458
623
  if (debug) {
459
- console.log(`[oxy.auth] OK user=${userId} (no session)`);
624
+ loggerUtils_1.logger.debug(`[oxy.auth] OK user=${userId} (no session)`, {
625
+ component: 'auth',
626
+ method: 'auth',
627
+ });
460
628
  }
461
629
  // Process X-Acting-As header before proceeding
462
630
  if (await processActingAs())
463
631
  next();
464
632
  }
465
633
  catch (error) {
466
- const apiError = oxyInstance.handleError(error);
634
+ const handled = oxyInstance.handleError(error);
635
+ const apiError = {
636
+ message: handled.message || 'Authentication error',
637
+ code: handled.code ?? 'AUTH_ERROR',
638
+ status: handled.status ?? 500,
639
+ details: handled.details,
640
+ };
467
641
  if (debug) {
468
- console.log(`[oxy.auth] Error:`, apiError);
642
+ loggerUtils_1.logger.debug('[oxy.auth] Error', {
643
+ component: 'auth',
644
+ method: 'auth',
645
+ }, apiError);
469
646
  }
470
647
  if (onError)
471
648
  return onError(apiError);
472
- return res.status((apiError && apiError.status) || 500).json(apiError);
649
+ return res.status(apiError.status).json(apiError);
473
650
  }
474
651
  };
475
652
  }
@@ -499,7 +676,7 @@ function OxyServicesUtilityMixin(Base) {
499
676
  */
500
677
  authSocket(options = {}) {
501
678
  const { debug = false } = options;
502
- // Cast to any for cross-mixin method access (Auth mixin methods available at runtime)
679
+ // Cross-mixin method access typed via the same structural subset.
503
680
  const oxyInstance = this;
504
681
  return async (socket, next) => {
505
682
  try {
@@ -511,15 +688,21 @@ function OxyServicesUtilityMixin(Base) {
511
688
  try {
512
689
  decoded = (0, jwt_decode_1.jwtDecode)(token);
513
690
  }
514
- catch {
691
+ catch (decodeError) {
692
+ if (debug) {
693
+ loggerUtils_1.logger.debug('[oxy.authSocket] Token decode failed', {
694
+ component: 'auth',
695
+ method: 'authSocket',
696
+ }, decodeError);
697
+ }
515
698
  return next(new Error('Invalid token'));
516
699
  }
517
700
  const userId = decoded.userId || decoded.id;
518
701
  if (!userId) {
519
702
  return next(new Error('Invalid token payload'));
520
703
  }
521
- // Check expiration
522
- if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
704
+ // Check expiration — reject tokens at exact expiry second (use <=)
705
+ if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
523
706
  return next(new Error('Token expired'));
524
707
  }
525
708
  // Validate session if available
@@ -532,25 +715,39 @@ function OxyServicesUtilityMixin(Base) {
532
715
  return next(new Error('Session invalid'));
533
716
  }
534
717
  }
535
- catch {
718
+ catch (validateErr) {
719
+ if (debug) {
720
+ loggerUtils_1.logger.debug('[oxy.authSocket] Session validation failed', {
721
+ component: 'auth',
722
+ method: 'authSocket',
723
+ }, validateErr);
724
+ }
536
725
  return next(new Error('Session validation failed'));
537
726
  }
538
727
  }
539
- // Attach user data to socket
728
+ // Attach user data to socket. We expose BOTH `socket.data.userId`
729
+ // (the official Socket.IO data slot) and `socket.user` because
730
+ // every consumer in this ecosystem (Mention, Allo, api/server.ts)
731
+ // reads from `socket.user.id`.
540
732
  socket.data = socket.data || {};
541
733
  socket.data.userId = userId;
542
734
  socket.data.sessionId = decoded.sessionId || null;
543
735
  socket.data.token = token;
544
- // Also set on socket.user for backward compatibility
545
736
  socket.user = { id: userId, userId, sessionId: decoded.sessionId };
546
737
  if (debug) {
547
- console.log(`[oxy.authSocket] OK user=${userId}`);
738
+ loggerUtils_1.logger.debug(`[oxy.authSocket] OK user=${userId}`, {
739
+ component: 'auth',
740
+ method: 'authSocket',
741
+ });
548
742
  }
549
743
  next();
550
744
  }
551
745
  catch (err) {
552
746
  if (debug) {
553
- console.log(`[oxy.authSocket] Error:`, err);
747
+ loggerUtils_1.logger.debug('[oxy.authSocket] Error', {
748
+ component: 'auth',
749
+ method: 'authSocket',
750
+ }, err);
554
751
  }
555
752
  next(new Error('Authentication error'));
556
753
  }
@@ -564,7 +761,7 @@ function OxyServicesUtilityMixin(Base) {
564
761
  * @example
565
762
  * ```typescript
566
763
  * // Protect internal endpoints
567
- * app.use('/internal', oxy.serviceAuth());
764
+ * app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
568
765
  *
569
766
  * app.post('/internal/trigger', (req, res) => {
570
767
  * console.log('Service app:', req.serviceApp);
@@ -587,5 +784,114 @@ function OxyServicesUtilityMixin(Base) {
587
784
  });
588
785
  };
589
786
  }
787
+ /**
788
+ * Express.js middleware that enforces a specific service-token scope.
789
+ *
790
+ * Mount AFTER `auth()` / `serviceAuth()` — relies on `req.serviceApp` and
791
+ * (when delegation is in effect) `req.serviceActingAs.scopes`. The scope
792
+ * is granted if EITHER list contains it, mirroring the OAuth2 model where
793
+ * the app's app-level scopes and the per-user delegated scopes both count.
794
+ *
795
+ * Requests authenticated as a regular user (no service token) are rejected
796
+ * with 403 — scope-protected endpoints are service-to-service by design.
797
+ *
798
+ * @example
799
+ * ```typescript
800
+ * app.use(
801
+ * '/internal/files',
802
+ * oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }),
803
+ * oxy.requireScope('files:write'),
804
+ * );
805
+ * ```
806
+ */
807
+ requireScope(scope) {
808
+ if (typeof scope !== 'string' || scope.length === 0) {
809
+ throw new Error('requireScope: scope must be a non-empty string');
810
+ }
811
+ return (req, res, next) => {
812
+ const appScopes = req.serviceApp?.scopes ?? [];
813
+ const delegatedScopes = req.serviceActingAs?.scopes ?? [];
814
+ if (!req.serviceApp) {
815
+ res.status(403).json({
816
+ error: 'SERVICE_TOKEN_REQUIRED',
817
+ message: 'Scope-protected endpoint requires a service token',
818
+ code: 'SERVICE_TOKEN_REQUIRED',
819
+ status: 403,
820
+ });
821
+ return;
822
+ }
823
+ if (appScopes.includes(scope) || delegatedScopes.includes(scope)) {
824
+ next();
825
+ return;
826
+ }
827
+ loggerUtils_1.logger.warn('[oxy.auth] Service token missing required scope', {
828
+ component: 'auth',
829
+ method: 'requireScope',
830
+ appId: req.serviceApp.appId,
831
+ required: scope,
832
+ });
833
+ res.status(403).json({
834
+ error: 'INSUFFICIENT_SCOPE',
835
+ message: `Required scope '${scope}' not granted`,
836
+ code: 'INSUFFICIENT_SCOPE',
837
+ status: 403,
838
+ });
839
+ };
840
+ }
590
841
  };
591
842
  }
843
+ // ---------------------------------------------------------------------------
844
+ // Service token verification helpers
845
+ // ---------------------------------------------------------------------------
846
+ /**
847
+ * Verify a service JWT's HMAC-SHA256 signature using a constant-time compare.
848
+ * Throws `ServiceTokenStructureError` on malformed tokens and
849
+ * `ServiceTokenSignatureError` on signature mismatch — both map to 401.
850
+ */
851
+ async function verifyServiceTokenSignature(token, secret) {
852
+ const nodeCrypto = await (0, platformCrypto_1.loadNodeCrypto)();
853
+ const { createHmac, timingSafeEqual } = nodeCrypto;
854
+ const parts = token.split('.');
855
+ if (parts.length !== 3) {
856
+ throw new ServiceTokenStructureError(`Service token must have 3 parts, got ${parts.length}`);
857
+ }
858
+ const [headerB64, payloadB64, signatureB64] = parts;
859
+ if (!headerB64 || !payloadB64 || !signatureB64) {
860
+ throw new ServiceTokenStructureError('Service token has empty segment');
861
+ }
862
+ const expectedSig = createHmac('sha256', secret)
863
+ .update(`${headerB64}.${payloadB64}`)
864
+ .digest('base64')
865
+ .replace(/\+/g, '-')
866
+ .replace(/\//g, '_')
867
+ .replace(/=/g, '');
868
+ const sigBuf = Buffer.from(signatureB64);
869
+ const expectedBuf = Buffer.from(expectedSig);
870
+ if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
871
+ throw new ServiceTokenSignatureError();
872
+ }
873
+ }
874
+ /**
875
+ * Verify that a decoded service-token payload carries the expected `aud`,
876
+ * `iss`, and `type` claims. Throws `ServiceTokenClaimError` on mismatch.
877
+ * This is the defence against the H4 vulnerability where a recovery / 2FA /
878
+ * access token signed by the same shared secret could be replayed as a
879
+ * service token because no claim binding existed.
880
+ */
881
+ function verifyServiceTokenClaims(decoded, expected) {
882
+ if (decoded.type !== 'service') {
883
+ throw new ServiceTokenClaimError(`Service token has unexpected type '${String(decoded.type)}'`);
884
+ }
885
+ if (decoded.iss !== expected.issuer) {
886
+ throw new ServiceTokenClaimError(`Service token issuer mismatch: expected '${expected.issuer}', got '${String(decoded.iss)}'`);
887
+ }
888
+ const aud = decoded.aud;
889
+ if (Array.isArray(aud)) {
890
+ if (!aud.includes(expected.audience)) {
891
+ throw new ServiceTokenClaimError(`Service token audience does not include '${expected.audience}'`);
892
+ }
893
+ }
894
+ else if (aud !== expected.audience) {
895
+ throw new ServiceTokenClaimError(`Service token audience mismatch: expected '${expected.audience}', got '${String(aud)}'`);
896
+ }
897
+ }