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