@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
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { jwtDecode } from 'jwt-decode';
|
|
8
8
|
import type { ApiError, User } from '../models/interfaces';
|
|
9
9
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
10
|
+
import { loadNodeCrypto } from '../utils/platformCrypto';
|
|
11
|
+
import { logger } from '../utils/loggerUtils';
|
|
10
12
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
11
13
|
|
|
12
14
|
interface JwtPayload {
|
|
@@ -17,7 +19,10 @@ interface JwtPayload {
|
|
|
17
19
|
type?: string;
|
|
18
20
|
appId?: string;
|
|
19
21
|
appName?: string;
|
|
20
|
-
|
|
22
|
+
scopes?: string[];
|
|
23
|
+
aud?: string | string[];
|
|
24
|
+
iss?: string;
|
|
25
|
+
[key: string]: unknown;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
/**
|
|
@@ -30,11 +35,67 @@ interface ActingAsVerification {
|
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
/**
|
|
33
|
-
*
|
|
38
|
+
* Result from the service-acting-as verification endpoint.
|
|
39
|
+
* Confirms that a given service app holds an active delegation grant for
|
|
40
|
+
* the supplied user, along with the explicit scope list the grant covers.
|
|
41
|
+
*
|
|
42
|
+
* The api side persists these via the `ServiceActingAs` model:
|
|
43
|
+
* { serviceAppId, userId, scopes: string[], grantedAt, expiresAt }
|
|
44
|
+
*
|
|
45
|
+
* The SDK never inspects the grant directly — it round-trips through
|
|
46
|
+
* `GET /internal/service-acting-as/verify?appId=...&userId=...` so the
|
|
47
|
+
* authoritative store stays server-side.
|
|
48
|
+
*/
|
|
49
|
+
export interface ServiceActingAsVerification {
|
|
50
|
+
authorized: boolean;
|
|
51
|
+
scopes: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Service app metadata attached to requests authenticated with service tokens.
|
|
56
|
+
* `scopes` reflects the scopes granted to the app at signup time (from the
|
|
57
|
+
* `DeveloperApp.scopes` field); route-level checks can require additional
|
|
58
|
+
* scope-narrowing via `requireScope()`.
|
|
34
59
|
*/
|
|
35
60
|
export interface ServiceApp {
|
|
36
61
|
appId: string;
|
|
37
62
|
appName: string;
|
|
63
|
+
scopes: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Expected JWT audience for tokens issued by the Oxy auth service.
|
|
68
|
+
*/
|
|
69
|
+
const OXY_JWT_AUDIENCE = 'oxy-api';
|
|
70
|
+
/**
|
|
71
|
+
* Expected JWT issuer for tokens issued by the Oxy auth service.
|
|
72
|
+
*/
|
|
73
|
+
const OXY_JWT_ISSUER = 'oxy-auth';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Sentinel error classes for service-token verification. Using classes (not
|
|
77
|
+
* message strings) makes the catch site below safe to extend: a new failure
|
|
78
|
+
* mode added later cannot silently fall through to the generic 500 branch.
|
|
79
|
+
*/
|
|
80
|
+
class ServiceTokenStructureError extends Error {
|
|
81
|
+
constructor(message = 'Service token has malformed structure') {
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = 'ServiceTokenStructureError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
class ServiceTokenSignatureError extends Error {
|
|
88
|
+
constructor(message = 'Service token signature is invalid') {
|
|
89
|
+
super(message);
|
|
90
|
+
this.name = 'ServiceTokenSignatureError';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
class ServiceTokenClaimError extends Error {
|
|
95
|
+
constructor(message: string) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = 'ServiceTokenClaimError';
|
|
98
|
+
}
|
|
38
99
|
}
|
|
39
100
|
|
|
40
101
|
/**
|
|
@@ -44,7 +105,7 @@ interface AuthMiddlewareOptions {
|
|
|
44
105
|
/** Enable debug logging (default: false) */
|
|
45
106
|
debug?: boolean;
|
|
46
107
|
/** Custom error handler - receives error object, can return response */
|
|
47
|
-
onError?: (error: ApiError) =>
|
|
108
|
+
onError?: (error: ApiError) => unknown;
|
|
48
109
|
/** Load full user profile from API (default: false for performance) */
|
|
49
110
|
loadUser?: boolean;
|
|
50
111
|
/** Optional auth - attach user if token present but don't block (default: false) */
|
|
@@ -53,8 +114,24 @@ interface AuthMiddlewareOptions {
|
|
|
53
114
|
* JWT secret for verifying service token signatures locally.
|
|
54
115
|
* When provided, service tokens will be cryptographically verified.
|
|
55
116
|
* When omitted, service tokens will be rejected (secure default).
|
|
117
|
+
*
|
|
118
|
+
* **Migration note (>=1.11.14):** the Oxy API now signs service tokens
|
|
119
|
+
* with a dedicated `SERVICE_TOKEN_SECRET` distinct from `ACCESS_TOKEN_SECRET`.
|
|
120
|
+
* Pass that value here. If you keep passing the access-token secret you will
|
|
121
|
+
* still verify ALL signed-by-Oxy tokens (which is the whole class of bug
|
|
122
|
+
* H4 was supposed to prevent — DO NOT do that in production).
|
|
56
123
|
*/
|
|
57
124
|
jwtSecret?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Expected JWT issuer. Defaults to `'oxy-auth'`. Override only if you run
|
|
127
|
+
* a private fork of the Oxy auth server under a different `iss` claim.
|
|
128
|
+
*/
|
|
129
|
+
expectedIssuer?: string;
|
|
130
|
+
/**
|
|
131
|
+
* Expected JWT audience. Defaults to `'oxy-api'`. Override only if your
|
|
132
|
+
* private fork mints tokens for a different audience.
|
|
133
|
+
*/
|
|
134
|
+
expectedAudience?: string;
|
|
58
135
|
}
|
|
59
136
|
|
|
60
137
|
export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
@@ -62,6 +139,19 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
62
139
|
/** @internal In-memory cache for acting-as verification results (TTL: 5 min) */
|
|
63
140
|
_actingAsCache = new Map<string, { result: ActingAsVerification | null; expiresAt: number }>();
|
|
64
141
|
|
|
142
|
+
/**
|
|
143
|
+
* In-memory cache for service-acting-as verification.
|
|
144
|
+
* Negative results are cached for 1min to avoid hammering the verify
|
|
145
|
+
* endpoint when a service is misconfigured; positive grants are cached
|
|
146
|
+
* for 5min to amortize the round-trip without holding stale grants too long.
|
|
147
|
+
* @internal
|
|
148
|
+
*/
|
|
149
|
+
_serviceActingAsCache = new Map<string, { result: ServiceActingAsVerification | null; expiresAt: number }>();
|
|
150
|
+
|
|
151
|
+
// TypeScript's mixin pattern requires `(...args: any[])` here — the
|
|
152
|
+
// constructor signature is a structural shape check the compiler enforces.
|
|
153
|
+
// Matches every other mixin in this package; do not change without a
|
|
154
|
+
// monorepo-wide refactor of the mixin pipeline.
|
|
65
155
|
constructor(...args: any[]) {
|
|
66
156
|
super(...(args as [any]));
|
|
67
157
|
}
|
|
@@ -98,7 +188,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
98
188
|
});
|
|
99
189
|
|
|
100
190
|
return result && result.authorized ? result : null;
|
|
101
|
-
} catch {
|
|
191
|
+
} catch (error) {
|
|
192
|
+
logger.warn('[oxy.auth] verifyActingAs lookup failed — caching negative result', {
|
|
193
|
+
component: 'auth',
|
|
194
|
+
method: 'verifyActingAs',
|
|
195
|
+
userId,
|
|
196
|
+
accountId,
|
|
197
|
+
}, error);
|
|
102
198
|
// Cache negative result for 1 minute to avoid hammering on transient errors
|
|
103
199
|
this._actingAsCache.set(cacheKey, {
|
|
104
200
|
result: null,
|
|
@@ -108,6 +204,66 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
108
204
|
}
|
|
109
205
|
}
|
|
110
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Verify that a service app holds an active delegation grant authorising
|
|
209
|
+
* it to act on behalf of `userId`. Returns the grant (with allowed scopes)
|
|
210
|
+
* on success or `null` if no valid grant exists. Negative answers are
|
|
211
|
+
* cached briefly to protect the verify endpoint from misconfigured callers.
|
|
212
|
+
*
|
|
213
|
+
* Implemented as a per-instance Map keyed by `appId:userId`. Cached
|
|
214
|
+
* positive grants live for 5 minutes (acceptable staleness window for an
|
|
215
|
+
* impersonation grant); revocations propagate within that window.
|
|
216
|
+
*
|
|
217
|
+
* @internal Used by the auth() middleware — not part of the public API
|
|
218
|
+
*/
|
|
219
|
+
async verifyServiceActingAs(
|
|
220
|
+
appId: string,
|
|
221
|
+
userId: string,
|
|
222
|
+
): Promise<ServiceActingAsVerification | null> {
|
|
223
|
+
const cacheKey = `${appId}:${userId}`;
|
|
224
|
+
const now = Date.now();
|
|
225
|
+
|
|
226
|
+
const cached = this._serviceActingAsCache.get(cacheKey);
|
|
227
|
+
if (cached && cached.expiresAt > now) {
|
|
228
|
+
return cached.result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const result = await this.makeRequest<ServiceActingAsVerification>(
|
|
233
|
+
'GET',
|
|
234
|
+
'/internal/service-acting-as/verify',
|
|
235
|
+
{ appId, userId },
|
|
236
|
+
{ cache: false, retry: false, timeout: 5000 },
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const authorized = Boolean(result && result.authorized);
|
|
240
|
+
const verified: ServiceActingAsVerification | null = authorized
|
|
241
|
+
? { authorized: true, scopes: Array.isArray(result.scopes) ? result.scopes : [] }
|
|
242
|
+
: null;
|
|
243
|
+
|
|
244
|
+
this._serviceActingAsCache.set(cacheKey, {
|
|
245
|
+
result: verified,
|
|
246
|
+
expiresAt: now + 5 * 60 * 1000,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return verified;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
logger.warn('[oxy.auth] verifyServiceActingAs lookup failed — caching negative result', {
|
|
252
|
+
component: 'auth',
|
|
253
|
+
method: 'verifyServiceActingAs',
|
|
254
|
+
appId,
|
|
255
|
+
userId,
|
|
256
|
+
}, error);
|
|
257
|
+
// Negative cache prevents a runaway loop if the verify endpoint is
|
|
258
|
+
// down, while still letting a real grant become visible within 60s.
|
|
259
|
+
this._serviceActingAsCache.set(cacheKey, {
|
|
260
|
+
result: null,
|
|
261
|
+
expiresAt: now + 1 * 60 * 1000,
|
|
262
|
+
});
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
111
267
|
/**
|
|
112
268
|
* Fetch link metadata
|
|
113
269
|
*/
|
|
@@ -145,9 +301,17 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
145
301
|
* - Security comes from API-based session validation (`validateSession()`)
|
|
146
302
|
* which checks the session server-side on every request
|
|
147
303
|
* - Service tokens (type: 'service') DO use cryptographic HMAC verification
|
|
148
|
-
* via the `jwtSecret` option, since they are stateless
|
|
304
|
+
* via the `jwtSecret` option, since they are stateless. Service tokens
|
|
305
|
+
* are additionally checked for `aud`, `iss`, and `type` claims to prevent
|
|
306
|
+
* cross-token-type confusion attacks.
|
|
149
307
|
* - The backend's own `authMiddleware` uses `jwt.verify()` because it has
|
|
150
|
-
* direct access to `ACCESS_TOKEN_SECRET
|
|
308
|
+
* direct access to `SERVICE_TOKEN_SECRET` / `ACCESS_TOKEN_SECRET`.
|
|
309
|
+
*
|
|
310
|
+
* **Service-token delegation (X-Oxy-User-Id):**
|
|
311
|
+
* When a service token is accompanied by `X-Oxy-User-Id`, the SDK calls
|
|
312
|
+
* `verifyServiceActingAs(appId, userId)` to confirm an explicit delegation
|
|
313
|
+
* grant exists before attaching `req.userId`. A missing/expired grant
|
|
314
|
+
* results in a 403 — there is no fail-open path.
|
|
151
315
|
*
|
|
152
316
|
* @example
|
|
153
317
|
* ```typescript
|
|
@@ -156,7 +320,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
156
320
|
* const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
157
321
|
*
|
|
158
322
|
* // Protect all routes under /protected
|
|
159
|
-
* app.use('/protected', oxy.auth());
|
|
323
|
+
* app.use('/protected', oxy.auth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
|
|
160
324
|
*
|
|
161
325
|
* // Access user in route handler
|
|
162
326
|
* app.get('/protected/me', (req, res) => {
|
|
@@ -168,27 +332,41 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
168
332
|
*
|
|
169
333
|
* // Optional auth - attach user if present, don't block if absent
|
|
170
334
|
* app.use('/public', oxy.auth({ optional: true }));
|
|
335
|
+
*
|
|
336
|
+
* // Require a specific scope on a service-token-protected route
|
|
337
|
+
* app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }), oxy.requireScope('files:write'));
|
|
171
338
|
* ```
|
|
172
339
|
*
|
|
173
340
|
* @param options Optional configuration
|
|
174
341
|
* @returns Express middleware function
|
|
175
342
|
*/
|
|
176
343
|
auth(options: AuthMiddlewareOptions = {}) {
|
|
177
|
-
const {
|
|
178
|
-
|
|
179
|
-
|
|
344
|
+
const {
|
|
345
|
+
debug = false,
|
|
346
|
+
onError,
|
|
347
|
+
loadUser = false,
|
|
348
|
+
optional = false,
|
|
349
|
+
jwtSecret,
|
|
350
|
+
expectedIssuer = OXY_JWT_ISSUER,
|
|
351
|
+
expectedAudience = OXY_JWT_AUDIENCE,
|
|
352
|
+
} = options;
|
|
353
|
+
// Cross-mixin method access: typed as a structural subset of the
|
|
354
|
+
// composed OxyServices we know we have at runtime.
|
|
355
|
+
const oxyInstance = this as unknown as OxyAuthInstance;
|
|
180
356
|
|
|
181
357
|
// Return an async middleware function
|
|
182
|
-
return async (req:
|
|
358
|
+
return async (req: AuthReq, res: AuthRes, next: AuthNext) => {
|
|
183
359
|
// Process X-Acting-As header for managed account identity delegation.
|
|
184
360
|
// Called after successful authentication, before next(). If the header
|
|
185
361
|
// is present, verifies authorization and swaps the request identity to
|
|
186
362
|
// the managed account, preserving the original user for audit trails.
|
|
187
363
|
const processActingAs = async (): Promise<boolean> => {
|
|
188
|
-
const actingAsUserId = req.headers['x-acting-as']
|
|
189
|
-
if (!actingAsUserId) return true; // No header, proceed normally
|
|
364
|
+
const actingAsUserId = req.headers['x-acting-as'];
|
|
365
|
+
if (!actingAsUserId || typeof actingAsUserId !== 'string') return true; // No header, proceed normally
|
|
366
|
+
const currentUserId = req.userId;
|
|
367
|
+
if (!currentUserId) return true; // No authenticated user yet — nothing to swap
|
|
190
368
|
|
|
191
|
-
const verification = await oxyInstance.verifyActingAs(
|
|
369
|
+
const verification = await oxyInstance.verifyActingAs(currentUserId, actingAsUserId);
|
|
192
370
|
if (!verification) {
|
|
193
371
|
const error = {
|
|
194
372
|
error: 'ACTING_AS_UNAUTHORIZED',
|
|
@@ -205,27 +383,29 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
205
383
|
}
|
|
206
384
|
|
|
207
385
|
// Preserve original user for audit trails
|
|
208
|
-
req.originalUser = { id:
|
|
386
|
+
req.originalUser = { id: currentUserId, ...(req.user ?? {}) };
|
|
209
387
|
req.actingAs = { userId: actingAsUserId, role: verification.role };
|
|
210
388
|
|
|
211
389
|
// Swap user identity to the managed account
|
|
212
390
|
req.userId = actingAsUserId;
|
|
213
|
-
req.user = { id: actingAsUserId } as
|
|
214
|
-
// Also set _id for routes that use Pattern B (req.user._id)
|
|
215
|
-
if (req.user) {
|
|
216
|
-
(req.user as any)._id = actingAsUserId;
|
|
217
|
-
}
|
|
391
|
+
req.user = { id: actingAsUserId, _id: actingAsUserId } as unknown as User;
|
|
218
392
|
|
|
219
393
|
if (debug) {
|
|
220
|
-
|
|
394
|
+
logger.debug(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${currentUserId}`, {
|
|
395
|
+
component: 'auth',
|
|
396
|
+
method: 'auth.processActingAs',
|
|
397
|
+
});
|
|
221
398
|
}
|
|
222
399
|
|
|
223
400
|
return true;
|
|
224
401
|
};
|
|
225
402
|
|
|
226
403
|
try {
|
|
227
|
-
// Extract token from Authorization header or query params
|
|
228
|
-
|
|
404
|
+
// Extract token from Authorization header or query params.
|
|
405
|
+
// Node/Express normalizes `Authorization` to a string; we guard
|
|
406
|
+
// against the (legal but unusual) string[] case anyway.
|
|
407
|
+
const rawAuthHeader = req.headers.authorization;
|
|
408
|
+
const authHeader = Array.isArray(rawAuthHeader) ? rawAuthHeader[0] : rawAuthHeader;
|
|
229
409
|
let token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
|
230
410
|
|
|
231
411
|
// Fallback to query params (useful for WebSocket upgrades)
|
|
@@ -236,7 +416,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
236
416
|
}
|
|
237
417
|
|
|
238
418
|
if (debug) {
|
|
239
|
-
|
|
419
|
+
logger.debug(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`, {
|
|
420
|
+
component: 'auth',
|
|
421
|
+
method: 'auth',
|
|
422
|
+
});
|
|
240
423
|
}
|
|
241
424
|
|
|
242
425
|
if (!token) {
|
|
@@ -261,6 +444,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
261
444
|
try {
|
|
262
445
|
decoded = jwtDecode<JwtPayload>(token);
|
|
263
446
|
} catch (decodeError) {
|
|
447
|
+
if (debug) {
|
|
448
|
+
logger.debug('[oxy.auth] Token decode failed', {
|
|
449
|
+
component: 'auth',
|
|
450
|
+
method: 'auth',
|
|
451
|
+
}, decodeError);
|
|
452
|
+
}
|
|
264
453
|
if (optional) {
|
|
265
454
|
req.userId = null;
|
|
266
455
|
req.user = null;
|
|
@@ -297,50 +486,73 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
297
486
|
return res.status(403).json(error);
|
|
298
487
|
}
|
|
299
488
|
|
|
300
|
-
// Verify JWT signature
|
|
489
|
+
// Verify JWT signature, then audience / issuer / type / appId claims.
|
|
490
|
+
//
|
|
491
|
+
// Signature verification uses a manual HMAC-SHA256 compare because
|
|
492
|
+
// this file ships into RN/web bundles where `jsonwebtoken` is
|
|
493
|
+
// unavailable. The middleware only ever runs on Node hosts (see
|
|
494
|
+
// platformCrypto's doc-comment), and `loadNodeCrypto` is per-
|
|
495
|
+
// platform: the RN variant throws so Metro never bundles a Node
|
|
496
|
+
// built-in reference.
|
|
301
497
|
try {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
const expectedSig = createHmac('sha256', jwtSecret)
|
|
308
|
-
.update(`${headerB64}.${payloadB64}`)
|
|
309
|
-
.digest('base64')
|
|
310
|
-
.replace(/\+/g, '-')
|
|
311
|
-
.replace(/\//g, '_')
|
|
312
|
-
.replace(/=/g, '');
|
|
313
|
-
|
|
314
|
-
// Timing-safe comparison
|
|
315
|
-
const sigBuf = Buffer.from(signatureB64);
|
|
316
|
-
const expectedBuf = Buffer.from(expectedSig);
|
|
317
|
-
const { timingSafeEqual } = await import('crypto');
|
|
318
|
-
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
|
319
|
-
throw new Error('Invalid signature');
|
|
320
|
-
}
|
|
498
|
+
await verifyServiceTokenSignature(token, jwtSecret);
|
|
499
|
+
verifyServiceTokenClaims(decoded, {
|
|
500
|
+
audience: expectedAudience,
|
|
501
|
+
issuer: expectedIssuer,
|
|
502
|
+
});
|
|
321
503
|
} catch (verifyError) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (
|
|
326
|
-
|
|
327
|
-
|
|
504
|
+
// Structure + signature + claim errors all map to 401. Anything
|
|
505
|
+
// else (e.g. Node crypto failing to load on a misconfigured host)
|
|
506
|
+
// genuinely IS a 500.
|
|
507
|
+
if (
|
|
508
|
+
verifyError instanceof ServiceTokenStructureError ||
|
|
509
|
+
verifyError instanceof ServiceTokenSignatureError ||
|
|
510
|
+
verifyError instanceof ServiceTokenClaimError
|
|
511
|
+
) {
|
|
512
|
+
if (debug) {
|
|
513
|
+
logger.debug('[oxy.auth] Service token rejected', {
|
|
514
|
+
component: 'auth',
|
|
515
|
+
method: 'auth.serviceToken',
|
|
516
|
+
reason: verifyError.name,
|
|
517
|
+
detail: verifyError.message,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
if (optional) {
|
|
521
|
+
req.userId = null;
|
|
522
|
+
req.user = null;
|
|
523
|
+
return next();
|
|
524
|
+
}
|
|
525
|
+
const code = verifyError instanceof ServiceTokenSignatureError
|
|
526
|
+
? 'INVALID_SERVICE_TOKEN'
|
|
527
|
+
: verifyError instanceof ServiceTokenStructureError
|
|
528
|
+
? 'INVALID_SERVICE_TOKEN'
|
|
529
|
+
: 'INVALID_SERVICE_TOKEN_CLAIMS';
|
|
530
|
+
const error = {
|
|
531
|
+
error: code,
|
|
532
|
+
message: verifyError.message,
|
|
533
|
+
code,
|
|
534
|
+
status: 401,
|
|
535
|
+
};
|
|
328
536
|
if (onError) return onError(error);
|
|
329
|
-
return res.status(
|
|
537
|
+
return res.status(401).json(error);
|
|
330
538
|
}
|
|
331
539
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
540
|
+
logger.error('[oxy.auth] Unexpected error during service token verification', verifyError, {
|
|
541
|
+
component: 'auth',
|
|
542
|
+
method: 'auth.serviceToken',
|
|
543
|
+
});
|
|
544
|
+
const error = {
|
|
545
|
+
error: 'AUTH_INTERNAL_ERROR',
|
|
546
|
+
message: 'Internal authentication error',
|
|
547
|
+
code: 'AUTH_INTERNAL_ERROR',
|
|
548
|
+
status: 500,
|
|
549
|
+
};
|
|
338
550
|
if (onError) return onError(error);
|
|
339
|
-
return res.status(
|
|
551
|
+
return res.status(500).json(error);
|
|
340
552
|
}
|
|
341
553
|
|
|
342
|
-
// Check expiration
|
|
343
|
-
if (decoded.exp && decoded.exp
|
|
554
|
+
// Check expiration — reject tokens at exact expiry second (use <=)
|
|
555
|
+
if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
|
|
344
556
|
if (optional) {
|
|
345
557
|
req.userId = null;
|
|
346
558
|
req.user = null;
|
|
@@ -352,7 +564,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
352
564
|
}
|
|
353
565
|
|
|
354
566
|
// Validate required service token fields
|
|
355
|
-
|
|
567
|
+
const appId = decoded.appId;
|
|
568
|
+
if (!appId) {
|
|
356
569
|
if (optional) {
|
|
357
570
|
req.userId = null;
|
|
358
571
|
req.user = null;
|
|
@@ -364,18 +577,54 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
364
577
|
}
|
|
365
578
|
|
|
366
579
|
// Read delegated user ID from header
|
|
367
|
-
const
|
|
580
|
+
const oxyUserIdRaw = req.headers['x-oxy-user-id'];
|
|
581
|
+
const oxyUserId = typeof oxyUserIdRaw === 'string' && oxyUserIdRaw.length > 0 ? oxyUserIdRaw : null;
|
|
582
|
+
|
|
583
|
+
// C3: a service may only act as a user when an explicit
|
|
584
|
+
// ServiceActingAs grant exists for that (appId, userId) pair.
|
|
585
|
+
// Without the grant we MUST refuse — silently attaching
|
|
586
|
+
// `req.userId = oxyUserId` would let any service impersonate
|
|
587
|
+
// any user simply by setting the header.
|
|
588
|
+
if (oxyUserId) {
|
|
589
|
+
const grant = await oxyInstance.verifyServiceActingAs(appId, oxyUserId);
|
|
590
|
+
if (!grant || !grant.authorized) {
|
|
591
|
+
logger.warn('[oxy.auth] Service token rejected — no delegation grant', {
|
|
592
|
+
component: 'auth',
|
|
593
|
+
method: 'auth.serviceToken',
|
|
594
|
+
appId,
|
|
595
|
+
attemptedUserId: oxyUserId,
|
|
596
|
+
});
|
|
597
|
+
const error = {
|
|
598
|
+
error: 'SERVICE_ACTING_AS_UNAUTHORIZED',
|
|
599
|
+
message: 'Service not authorized to act as this user',
|
|
600
|
+
code: 'SERVICE_ACTING_AS_UNAUTHORIZED',
|
|
601
|
+
status: 403,
|
|
602
|
+
};
|
|
603
|
+
if (onError) return onError(error);
|
|
604
|
+
return res.status(403).json(error);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
req.userId = oxyUserId;
|
|
608
|
+
req.user = { id: oxyUserId } as User;
|
|
609
|
+
req.serviceActingAs = { userId: oxyUserId, scopes: grant.scopes };
|
|
610
|
+
} else {
|
|
611
|
+
// No X-Oxy-User-Id means the service is acting as itself.
|
|
612
|
+
req.userId = null;
|
|
613
|
+
req.user = null;
|
|
614
|
+
}
|
|
368
615
|
|
|
369
|
-
req.userId = oxyUserId || null;
|
|
370
|
-
req.user = oxyUserId ? ({ id: oxyUserId } as User) : null;
|
|
371
616
|
req.accessToken = token;
|
|
372
617
|
req.serviceApp = {
|
|
373
|
-
appId
|
|
618
|
+
appId,
|
|
374
619
|
appName: decoded.appName || 'unknown',
|
|
620
|
+
scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
|
|
375
621
|
};
|
|
376
622
|
|
|
377
623
|
if (debug) {
|
|
378
|
-
|
|
624
|
+
logger.debug(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`, {
|
|
625
|
+
component: 'auth',
|
|
626
|
+
method: 'auth.serviceToken',
|
|
627
|
+
});
|
|
379
628
|
}
|
|
380
629
|
|
|
381
630
|
return next();
|
|
@@ -400,7 +649,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
400
649
|
}
|
|
401
650
|
|
|
402
651
|
// Check token expiration locally first (fast path)
|
|
403
|
-
|
|
652
|
+
// Reject tokens at exact expiry second (use <=)
|
|
653
|
+
if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
|
|
404
654
|
if (optional) {
|
|
405
655
|
req.userId = null;
|
|
406
656
|
req.user = null;
|
|
@@ -455,7 +705,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
455
705
|
}
|
|
456
706
|
|
|
457
707
|
if (debug) {
|
|
458
|
-
|
|
708
|
+
logger.debug(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`, {
|
|
709
|
+
component: 'auth',
|
|
710
|
+
method: 'auth',
|
|
711
|
+
});
|
|
459
712
|
}
|
|
460
713
|
|
|
461
714
|
// Process X-Acting-As header before proceeding
|
|
@@ -463,7 +716,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
463
716
|
return;
|
|
464
717
|
} catch (validationError) {
|
|
465
718
|
if (debug) {
|
|
466
|
-
|
|
719
|
+
logger.debug('[oxy.auth] Session validation failed', {
|
|
720
|
+
component: 'auth',
|
|
721
|
+
method: 'auth',
|
|
722
|
+
}, validationError);
|
|
467
723
|
}
|
|
468
724
|
|
|
469
725
|
if (optional) {
|
|
@@ -505,26 +761,49 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
505
761
|
if (fullUser) {
|
|
506
762
|
req.user = fullUser;
|
|
507
763
|
}
|
|
508
|
-
} catch {
|
|
509
|
-
//
|
|
764
|
+
} catch (loadUserError) {
|
|
765
|
+
// Loading the full user is best-effort here; the basic { id }
|
|
766
|
+
// object is already attached. Log so misconfigured deployments
|
|
767
|
+
// can be diagnosed instead of silently failing.
|
|
768
|
+
logger.warn('[oxy.auth] loadUser fallback — could not fetch full profile', {
|
|
769
|
+
component: 'auth',
|
|
770
|
+
method: 'auth.loadUser',
|
|
771
|
+
userId,
|
|
772
|
+
}, loadUserError);
|
|
510
773
|
}
|
|
511
774
|
}
|
|
512
775
|
|
|
513
776
|
if (debug) {
|
|
514
|
-
|
|
777
|
+
logger.debug(`[oxy.auth] OK user=${userId} (no session)`, {
|
|
778
|
+
component: 'auth',
|
|
779
|
+
method: 'auth',
|
|
780
|
+
});
|
|
515
781
|
}
|
|
516
782
|
|
|
517
783
|
// Process X-Acting-As header before proceeding
|
|
518
784
|
if (await processActingAs()) next();
|
|
519
785
|
} catch (error) {
|
|
520
|
-
const
|
|
786
|
+
const handled = oxyInstance.handleError(error) as Error & {
|
|
787
|
+
code?: string;
|
|
788
|
+
status?: number;
|
|
789
|
+
details?: Record<string, unknown>;
|
|
790
|
+
};
|
|
791
|
+
const apiError: ApiError = {
|
|
792
|
+
message: handled.message || 'Authentication error',
|
|
793
|
+
code: handled.code ?? 'AUTH_ERROR',
|
|
794
|
+
status: handled.status ?? 500,
|
|
795
|
+
details: handled.details,
|
|
796
|
+
};
|
|
521
797
|
|
|
522
798
|
if (debug) {
|
|
523
|
-
|
|
799
|
+
logger.debug('[oxy.auth] Error', {
|
|
800
|
+
component: 'auth',
|
|
801
|
+
method: 'auth',
|
|
802
|
+
}, apiError);
|
|
524
803
|
}
|
|
525
804
|
|
|
526
805
|
if (onError) return onError(apiError);
|
|
527
|
-
return res.status(
|
|
806
|
+
return res.status(apiError.status).json(apiError);
|
|
528
807
|
}
|
|
529
808
|
};
|
|
530
809
|
}
|
|
@@ -555,10 +834,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
555
834
|
*/
|
|
556
835
|
authSocket(options: { debug?: boolean } = {}) {
|
|
557
836
|
const { debug = false } = options;
|
|
558
|
-
//
|
|
559
|
-
const oxyInstance = this as
|
|
837
|
+
// Cross-mixin method access typed via the same structural subset.
|
|
838
|
+
const oxyInstance = this as unknown as OxyAuthInstance;
|
|
560
839
|
|
|
561
|
-
return async (socket:
|
|
840
|
+
return async (socket: SocketLike, next: (err?: Error) => void) => {
|
|
562
841
|
try {
|
|
563
842
|
const token = socket.handshake?.auth?.token;
|
|
564
843
|
|
|
@@ -569,7 +848,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
569
848
|
let decoded: JwtPayload;
|
|
570
849
|
try {
|
|
571
850
|
decoded = jwtDecode<JwtPayload>(token);
|
|
572
|
-
} catch {
|
|
851
|
+
} catch (decodeError) {
|
|
852
|
+
if (debug) {
|
|
853
|
+
logger.debug('[oxy.authSocket] Token decode failed', {
|
|
854
|
+
component: 'auth',
|
|
855
|
+
method: 'authSocket',
|
|
856
|
+
}, decodeError);
|
|
857
|
+
}
|
|
573
858
|
return next(new Error('Invalid token'));
|
|
574
859
|
}
|
|
575
860
|
|
|
@@ -578,8 +863,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
578
863
|
return next(new Error('Invalid token payload'));
|
|
579
864
|
}
|
|
580
865
|
|
|
581
|
-
// Check expiration
|
|
582
|
-
if (decoded.exp && decoded.exp
|
|
866
|
+
// Check expiration — reject tokens at exact expiry second (use <=)
|
|
867
|
+
if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
|
|
583
868
|
return next(new Error('Token expired'));
|
|
584
869
|
}
|
|
585
870
|
|
|
@@ -592,28 +877,42 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
592
877
|
if (!result || !result.valid) {
|
|
593
878
|
return next(new Error('Session invalid'));
|
|
594
879
|
}
|
|
595
|
-
} catch {
|
|
880
|
+
} catch (validateErr) {
|
|
881
|
+
if (debug) {
|
|
882
|
+
logger.debug('[oxy.authSocket] Session validation failed', {
|
|
883
|
+
component: 'auth',
|
|
884
|
+
method: 'authSocket',
|
|
885
|
+
}, validateErr);
|
|
886
|
+
}
|
|
596
887
|
return next(new Error('Session validation failed'));
|
|
597
888
|
}
|
|
598
889
|
}
|
|
599
890
|
|
|
600
|
-
// Attach user data to socket
|
|
891
|
+
// Attach user data to socket. We expose BOTH `socket.data.userId`
|
|
892
|
+
// (the official Socket.IO data slot) and `socket.user` because
|
|
893
|
+
// every consumer in this ecosystem (Mention, Allo, api/server.ts)
|
|
894
|
+
// reads from `socket.user.id`.
|
|
601
895
|
socket.data = socket.data || {};
|
|
602
896
|
socket.data.userId = userId;
|
|
603
897
|
socket.data.sessionId = decoded.sessionId || null;
|
|
604
898
|
socket.data.token = token;
|
|
605
899
|
|
|
606
|
-
// Also set on socket.user for backward compatibility
|
|
607
900
|
socket.user = { id: userId, userId, sessionId: decoded.sessionId };
|
|
608
901
|
|
|
609
902
|
if (debug) {
|
|
610
|
-
|
|
903
|
+
logger.debug(`[oxy.authSocket] OK user=${userId}`, {
|
|
904
|
+
component: 'auth',
|
|
905
|
+
method: 'authSocket',
|
|
906
|
+
});
|
|
611
907
|
}
|
|
612
908
|
|
|
613
909
|
next();
|
|
614
910
|
} catch (err) {
|
|
615
911
|
if (debug) {
|
|
616
|
-
|
|
912
|
+
logger.debug('[oxy.authSocket] Error', {
|
|
913
|
+
component: 'auth',
|
|
914
|
+
method: 'authSocket',
|
|
915
|
+
}, err);
|
|
617
916
|
}
|
|
618
917
|
next(new Error('Authentication error'));
|
|
619
918
|
}
|
|
@@ -627,7 +926,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
627
926
|
* @example
|
|
628
927
|
* ```typescript
|
|
629
928
|
* // Protect internal endpoints
|
|
630
|
-
* app.use('/internal', oxy.serviceAuth());
|
|
929
|
+
* app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
|
|
631
930
|
*
|
|
632
931
|
* app.post('/internal/trigger', (req, res) => {
|
|
633
932
|
* console.log('Service app:', req.serviceApp);
|
|
@@ -635,10 +934,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
635
934
|
* });
|
|
636
935
|
* ```
|
|
637
936
|
*/
|
|
638
|
-
serviceAuth(options: { debug?: boolean; jwtSecret?: string } = {}) {
|
|
937
|
+
serviceAuth(options: { debug?: boolean; jwtSecret?: string; expectedIssuer?: string; expectedAudience?: string } = {}) {
|
|
639
938
|
const innerAuth = this.auth({ ...options });
|
|
640
939
|
|
|
641
|
-
return async (req:
|
|
940
|
+
return async (req: AuthReq, res: AuthRes, next: AuthNext) => {
|
|
642
941
|
await innerAuth(req, res, () => {
|
|
643
942
|
if (!req.serviceApp) {
|
|
644
943
|
return res.status(403).json({
|
|
@@ -651,6 +950,180 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
651
950
|
});
|
|
652
951
|
};
|
|
653
952
|
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Express.js middleware that enforces a specific service-token scope.
|
|
956
|
+
*
|
|
957
|
+
* Mount AFTER `auth()` / `serviceAuth()` — relies on `req.serviceApp` and
|
|
958
|
+
* (when delegation is in effect) `req.serviceActingAs.scopes`. The scope
|
|
959
|
+
* is granted if EITHER list contains it, mirroring the OAuth2 model where
|
|
960
|
+
* the app's app-level scopes and the per-user delegated scopes both count.
|
|
961
|
+
*
|
|
962
|
+
* Requests authenticated as a regular user (no service token) are rejected
|
|
963
|
+
* with 403 — scope-protected endpoints are service-to-service by design.
|
|
964
|
+
*
|
|
965
|
+
* @example
|
|
966
|
+
* ```typescript
|
|
967
|
+
* app.use(
|
|
968
|
+
* '/internal/files',
|
|
969
|
+
* oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }),
|
|
970
|
+
* oxy.requireScope('files:write'),
|
|
971
|
+
* );
|
|
972
|
+
* ```
|
|
973
|
+
*/
|
|
974
|
+
requireScope(scope: string) {
|
|
975
|
+
if (typeof scope !== 'string' || scope.length === 0) {
|
|
976
|
+
throw new Error('requireScope: scope must be a non-empty string');
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
return (req: AuthReq, res: AuthRes, next: AuthNext): void => {
|
|
980
|
+
const appScopes = req.serviceApp?.scopes ?? [];
|
|
981
|
+
const delegatedScopes = req.serviceActingAs?.scopes ?? [];
|
|
982
|
+
|
|
983
|
+
if (!req.serviceApp) {
|
|
984
|
+
res.status(403).json({
|
|
985
|
+
error: 'SERVICE_TOKEN_REQUIRED',
|
|
986
|
+
message: 'Scope-protected endpoint requires a service token',
|
|
987
|
+
code: 'SERVICE_TOKEN_REQUIRED',
|
|
988
|
+
status: 403,
|
|
989
|
+
});
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
if (appScopes.includes(scope) || delegatedScopes.includes(scope)) {
|
|
994
|
+
next();
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
logger.warn('[oxy.auth] Service token missing required scope', {
|
|
999
|
+
component: 'auth',
|
|
1000
|
+
method: 'requireScope',
|
|
1001
|
+
appId: req.serviceApp.appId,
|
|
1002
|
+
required: scope,
|
|
1003
|
+
});
|
|
1004
|
+
res.status(403).json({
|
|
1005
|
+
error: 'INSUFFICIENT_SCOPE',
|
|
1006
|
+
message: `Required scope '${scope}' not granted`,
|
|
1007
|
+
code: 'INSUFFICIENT_SCOPE',
|
|
1008
|
+
status: 403,
|
|
1009
|
+
});
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
654
1012
|
};
|
|
655
1013
|
}
|
|
656
1014
|
|
|
1015
|
+
// ---------------------------------------------------------------------------
|
|
1016
|
+
// Service token verification helpers
|
|
1017
|
+
// ---------------------------------------------------------------------------
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Verify a service JWT's HMAC-SHA256 signature using a constant-time compare.
|
|
1021
|
+
* Throws `ServiceTokenStructureError` on malformed tokens and
|
|
1022
|
+
* `ServiceTokenSignatureError` on signature mismatch — both map to 401.
|
|
1023
|
+
*/
|
|
1024
|
+
async function verifyServiceTokenSignature(token: string, secret: string): Promise<void> {
|
|
1025
|
+
const nodeCrypto = await loadNodeCrypto();
|
|
1026
|
+
const { createHmac, timingSafeEqual } = nodeCrypto;
|
|
1027
|
+
const parts = token.split('.');
|
|
1028
|
+
if (parts.length !== 3) {
|
|
1029
|
+
throw new ServiceTokenStructureError(`Service token must have 3 parts, got ${parts.length}`);
|
|
1030
|
+
}
|
|
1031
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
1032
|
+
if (!headerB64 || !payloadB64 || !signatureB64) {
|
|
1033
|
+
throw new ServiceTokenStructureError('Service token has empty segment');
|
|
1034
|
+
}
|
|
1035
|
+
const expectedSig = createHmac('sha256', secret)
|
|
1036
|
+
.update(`${headerB64}.${payloadB64}`)
|
|
1037
|
+
.digest('base64')
|
|
1038
|
+
.replace(/\+/g, '-')
|
|
1039
|
+
.replace(/\//g, '_')
|
|
1040
|
+
.replace(/=/g, '');
|
|
1041
|
+
|
|
1042
|
+
const sigBuf = Buffer.from(signatureB64);
|
|
1043
|
+
const expectedBuf = Buffer.from(expectedSig);
|
|
1044
|
+
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
|
1045
|
+
throw new ServiceTokenSignatureError();
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Verify that a decoded service-token payload carries the expected `aud`,
|
|
1051
|
+
* `iss`, and `type` claims. Throws `ServiceTokenClaimError` on mismatch.
|
|
1052
|
+
* This is the defence against the H4 vulnerability where a recovery / 2FA /
|
|
1053
|
+
* access token signed by the same shared secret could be replayed as a
|
|
1054
|
+
* service token because no claim binding existed.
|
|
1055
|
+
*/
|
|
1056
|
+
function verifyServiceTokenClaims(
|
|
1057
|
+
decoded: JwtPayload,
|
|
1058
|
+
expected: { audience: string; issuer: string },
|
|
1059
|
+
): void {
|
|
1060
|
+
if (decoded.type !== 'service') {
|
|
1061
|
+
throw new ServiceTokenClaimError(`Service token has unexpected type '${String(decoded.type)}'`);
|
|
1062
|
+
}
|
|
1063
|
+
if (decoded.iss !== expected.issuer) {
|
|
1064
|
+
throw new ServiceTokenClaimError(`Service token issuer mismatch: expected '${expected.issuer}', got '${String(decoded.iss)}'`);
|
|
1065
|
+
}
|
|
1066
|
+
const aud = decoded.aud;
|
|
1067
|
+
if (Array.isArray(aud)) {
|
|
1068
|
+
if (!aud.includes(expected.audience)) {
|
|
1069
|
+
throw new ServiceTokenClaimError(`Service token audience does not include '${expected.audience}'`);
|
|
1070
|
+
}
|
|
1071
|
+
} else if (aud !== expected.audience) {
|
|
1072
|
+
throw new ServiceTokenClaimError(`Service token audience mismatch: expected '${expected.audience}', got '${String(aud)}'`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// ---------------------------------------------------------------------------
|
|
1077
|
+
// Local request/response/socket typing
|
|
1078
|
+
//
|
|
1079
|
+
// Express's types are an optional peer (we don't want to take a hard dep on
|
|
1080
|
+
// `@types/express` from a platform-agnostic SDK). The structural subset below
|
|
1081
|
+
// captures everything this middleware actually touches, so consumers get type
|
|
1082
|
+
// checking without us coupling to Express's full surface.
|
|
1083
|
+
// ---------------------------------------------------------------------------
|
|
1084
|
+
|
|
1085
|
+
interface AuthReq {
|
|
1086
|
+
method?: string;
|
|
1087
|
+
path?: string;
|
|
1088
|
+
headers: Record<string, string | string[] | undefined>;
|
|
1089
|
+
query?: Record<string, unknown>;
|
|
1090
|
+
userId?: string | null;
|
|
1091
|
+
user?: User | null;
|
|
1092
|
+
accessToken?: string;
|
|
1093
|
+
sessionId?: string | null;
|
|
1094
|
+
serviceApp?: ServiceApp;
|
|
1095
|
+
serviceActingAs?: { userId: string; scopes: string[] };
|
|
1096
|
+
actingAs?: { userId: string; role: string };
|
|
1097
|
+
originalUser?: { id: string } & Partial<User>;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
interface AuthRes {
|
|
1101
|
+
status(code: number): AuthRes;
|
|
1102
|
+
json(body: unknown): unknown;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
type AuthNext = (err?: unknown) => void;
|
|
1106
|
+
|
|
1107
|
+
interface SocketLike {
|
|
1108
|
+
handshake?: { auth?: { token?: string } };
|
|
1109
|
+
data?: Record<string, unknown>;
|
|
1110
|
+
user?: { id: string; userId: string; sessionId?: string | null };
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
interface OxyAuthInstance {
|
|
1114
|
+
verifyActingAs(userId: string, accountId: string): Promise<ActingAsVerification | null>;
|
|
1115
|
+
verifyServiceActingAs(appId: string, userId: string): Promise<ServiceActingAsVerification | null>;
|
|
1116
|
+
validateSession(
|
|
1117
|
+
sessionId: string,
|
|
1118
|
+
options?: { deviceFingerprint?: string; useHeaderValidation?: boolean },
|
|
1119
|
+
): Promise<{
|
|
1120
|
+
valid: boolean;
|
|
1121
|
+
user?: User;
|
|
1122
|
+
[key: string]: unknown;
|
|
1123
|
+
} | null>;
|
|
1124
|
+
getAccessToken(): string | null;
|
|
1125
|
+
setTokens(accessToken: string, refreshToken?: string): void;
|
|
1126
|
+
clearTokens(): void;
|
|
1127
|
+
getCurrentUser(): Promise<User | null>;
|
|
1128
|
+
handleError(error: unknown): Error;
|
|
1129
|
+
}
|