@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
|
@@ -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
|
-
//
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
410
|
+
return res.status(500).json(error);
|
|
297
411
|
}
|
|
298
|
-
// Check expiration
|
|
299
|
-
if (decoded.exp && decoded.exp
|
|
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
|
-
|
|
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
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
475
|
+
appId,
|
|
329
476
|
appName: decoded.appName || 'unknown',
|
|
477
|
+
scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
|
|
330
478
|
};
|
|
331
479
|
if (debug) {
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|