@oxyhq/core 1.11.13 → 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/index.js +5 -2
- package/dist/cjs/mixins/OxyServices.auth.js +133 -27
- package/dist/cjs/mixins/OxyServices.utility.js +405 -75
- package/dist/cjs/utils/languageUtils.js +22 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +2 -1
- package/dist/esm/mixins/OxyServices.auth.js +131 -27
- package/dist/esm/mixins/OxyServices.utility.js +405 -75
- package/dist/esm/utils/languageUtils.js +21 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +14 -4
- package/dist/types/index.d.ts +3 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +72 -11
- package/dist/types/mixins/OxyServices.utility.d.ts +144 -10
- package/dist/types/utils/languageUtils.d.ts +1 -0
- package/package.json +18 -2
- package/src/OxyServices.ts +17 -3
- package/src/index.ts +3 -1
- package/src/mixins/OxyServices.auth.ts +160 -28
- package/src/mixins/OxyServices.utility.ts +551 -87
- package/src/mixins/__tests__/serviceAuth.test.ts +623 -0
- package/src/utils/languageUtils.ts +23 -2
|
@@ -8,6 +8,7 @@ import { jwtDecode } from 'jwt-decode';
|
|
|
8
8
|
import type { ApiError, User } from '../models/interfaces';
|
|
9
9
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
10
10
|
import { loadNodeCrypto } from '../utils/platformCrypto';
|
|
11
|
+
import { logger } from '../utils/loggerUtils';
|
|
11
12
|
import { CACHE_TIMES } from './mixinHelpers';
|
|
12
13
|
|
|
13
14
|
interface JwtPayload {
|
|
@@ -18,7 +19,10 @@ interface JwtPayload {
|
|
|
18
19
|
type?: string;
|
|
19
20
|
appId?: string;
|
|
20
21
|
appName?: string;
|
|
21
|
-
|
|
22
|
+
scopes?: string[];
|
|
23
|
+
aud?: string | string[];
|
|
24
|
+
iss?: string;
|
|
25
|
+
[key: string]: unknown;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
/**
|
|
@@ -31,11 +35,67 @@ interface ActingAsVerification {
|
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
|
-
*
|
|
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()`.
|
|
35
59
|
*/
|
|
36
60
|
export interface ServiceApp {
|
|
37
61
|
appId: string;
|
|
38
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
|
+
}
|
|
39
99
|
}
|
|
40
100
|
|
|
41
101
|
/**
|
|
@@ -45,7 +105,7 @@ interface AuthMiddlewareOptions {
|
|
|
45
105
|
/** Enable debug logging (default: false) */
|
|
46
106
|
debug?: boolean;
|
|
47
107
|
/** Custom error handler - receives error object, can return response */
|
|
48
|
-
onError?: (error: ApiError) =>
|
|
108
|
+
onError?: (error: ApiError) => unknown;
|
|
49
109
|
/** Load full user profile from API (default: false for performance) */
|
|
50
110
|
loadUser?: boolean;
|
|
51
111
|
/** Optional auth - attach user if token present but don't block (default: false) */
|
|
@@ -54,8 +114,24 @@ interface AuthMiddlewareOptions {
|
|
|
54
114
|
* JWT secret for verifying service token signatures locally.
|
|
55
115
|
* When provided, service tokens will be cryptographically verified.
|
|
56
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).
|
|
57
123
|
*/
|
|
58
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;
|
|
59
135
|
}
|
|
60
136
|
|
|
61
137
|
export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
@@ -63,6 +139,19 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
63
139
|
/** @internal In-memory cache for acting-as verification results (TTL: 5 min) */
|
|
64
140
|
_actingAsCache = new Map<string, { result: ActingAsVerification | null; expiresAt: number }>();
|
|
65
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.
|
|
66
155
|
constructor(...args: any[]) {
|
|
67
156
|
super(...(args as [any]));
|
|
68
157
|
}
|
|
@@ -99,7 +188,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
99
188
|
});
|
|
100
189
|
|
|
101
190
|
return result && result.authorized ? result : null;
|
|
102
|
-
} 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);
|
|
103
198
|
// Cache negative result for 1 minute to avoid hammering on transient errors
|
|
104
199
|
this._actingAsCache.set(cacheKey, {
|
|
105
200
|
result: null,
|
|
@@ -109,6 +204,66 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
109
204
|
}
|
|
110
205
|
}
|
|
111
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
|
+
|
|
112
267
|
/**
|
|
113
268
|
* Fetch link metadata
|
|
114
269
|
*/
|
|
@@ -146,9 +301,17 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
146
301
|
* - Security comes from API-based session validation (`validateSession()`)
|
|
147
302
|
* which checks the session server-side on every request
|
|
148
303
|
* - Service tokens (type: 'service') DO use cryptographic HMAC verification
|
|
149
|
-
* 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.
|
|
150
307
|
* - The backend's own `authMiddleware` uses `jwt.verify()` because it has
|
|
151
|
-
* 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.
|
|
152
315
|
*
|
|
153
316
|
* @example
|
|
154
317
|
* ```typescript
|
|
@@ -157,7 +320,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
157
320
|
* const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
158
321
|
*
|
|
159
322
|
* // Protect all routes under /protected
|
|
160
|
-
* app.use('/protected', oxy.auth());
|
|
323
|
+
* app.use('/protected', oxy.auth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
|
|
161
324
|
*
|
|
162
325
|
* // Access user in route handler
|
|
163
326
|
* app.get('/protected/me', (req, res) => {
|
|
@@ -169,27 +332,41 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
169
332
|
*
|
|
170
333
|
* // Optional auth - attach user if present, don't block if absent
|
|
171
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'));
|
|
172
338
|
* ```
|
|
173
339
|
*
|
|
174
340
|
* @param options Optional configuration
|
|
175
341
|
* @returns Express middleware function
|
|
176
342
|
*/
|
|
177
343
|
auth(options: AuthMiddlewareOptions = {}) {
|
|
178
|
-
const {
|
|
179
|
-
|
|
180
|
-
|
|
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;
|
|
181
356
|
|
|
182
357
|
// Return an async middleware function
|
|
183
|
-
return async (req:
|
|
358
|
+
return async (req: AuthReq, res: AuthRes, next: AuthNext) => {
|
|
184
359
|
// Process X-Acting-As header for managed account identity delegation.
|
|
185
360
|
// Called after successful authentication, before next(). If the header
|
|
186
361
|
// is present, verifies authorization and swaps the request identity to
|
|
187
362
|
// the managed account, preserving the original user for audit trails.
|
|
188
363
|
const processActingAs = async (): Promise<boolean> => {
|
|
189
|
-
const actingAsUserId = req.headers['x-acting-as']
|
|
190
|
-
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
|
|
191
368
|
|
|
192
|
-
const verification = await oxyInstance.verifyActingAs(
|
|
369
|
+
const verification = await oxyInstance.verifyActingAs(currentUserId, actingAsUserId);
|
|
193
370
|
if (!verification) {
|
|
194
371
|
const error = {
|
|
195
372
|
error: 'ACTING_AS_UNAUTHORIZED',
|
|
@@ -206,27 +383,29 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
206
383
|
}
|
|
207
384
|
|
|
208
385
|
// Preserve original user for audit trails
|
|
209
|
-
req.originalUser = { id:
|
|
386
|
+
req.originalUser = { id: currentUserId, ...(req.user ?? {}) };
|
|
210
387
|
req.actingAs = { userId: actingAsUserId, role: verification.role };
|
|
211
388
|
|
|
212
389
|
// Swap user identity to the managed account
|
|
213
390
|
req.userId = actingAsUserId;
|
|
214
|
-
req.user = { id: actingAsUserId } as
|
|
215
|
-
// Also set _id for routes that use Pattern B (req.user._id)
|
|
216
|
-
if (req.user) {
|
|
217
|
-
(req.user as any)._id = actingAsUserId;
|
|
218
|
-
}
|
|
391
|
+
req.user = { id: actingAsUserId, _id: actingAsUserId } as unknown as User;
|
|
219
392
|
|
|
220
393
|
if (debug) {
|
|
221
|
-
|
|
394
|
+
logger.debug(`[oxy.auth] Acting as ${actingAsUserId} (role=${verification.role}) original=${currentUserId}`, {
|
|
395
|
+
component: 'auth',
|
|
396
|
+
method: 'auth.processActingAs',
|
|
397
|
+
});
|
|
222
398
|
}
|
|
223
399
|
|
|
224
400
|
return true;
|
|
225
401
|
};
|
|
226
402
|
|
|
227
403
|
try {
|
|
228
|
-
// Extract token from Authorization header or query params
|
|
229
|
-
|
|
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;
|
|
230
409
|
let token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
|
231
410
|
|
|
232
411
|
// Fallback to query params (useful for WebSocket upgrades)
|
|
@@ -237,7 +416,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
237
416
|
}
|
|
238
417
|
|
|
239
418
|
if (debug) {
|
|
240
|
-
|
|
419
|
+
logger.debug(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`, {
|
|
420
|
+
component: 'auth',
|
|
421
|
+
method: 'auth',
|
|
422
|
+
});
|
|
241
423
|
}
|
|
242
424
|
|
|
243
425
|
if (!token) {
|
|
@@ -262,6 +444,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
262
444
|
try {
|
|
263
445
|
decoded = jwtDecode<JwtPayload>(token);
|
|
264
446
|
} catch (decodeError) {
|
|
447
|
+
if (debug) {
|
|
448
|
+
logger.debug('[oxy.auth] Token decode failed', {
|
|
449
|
+
component: 'auth',
|
|
450
|
+
method: 'auth',
|
|
451
|
+
}, decodeError);
|
|
452
|
+
}
|
|
265
453
|
if (optional) {
|
|
266
454
|
req.userId = null;
|
|
267
455
|
req.user = null;
|
|
@@ -298,51 +486,69 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
298
486
|
return res.status(403).json(error);
|
|
299
487
|
}
|
|
300
488
|
|
|
301
|
-
// Verify JWT signature
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
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.
|
|
307
497
|
try {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
const expectedSig = createHmac('sha256', jwtSecret)
|
|
315
|
-
.update(`${headerB64}.${payloadB64}`)
|
|
316
|
-
.digest('base64')
|
|
317
|
-
.replace(/\+/g, '-')
|
|
318
|
-
.replace(/\//g, '_')
|
|
319
|
-
.replace(/=/g, '');
|
|
320
|
-
|
|
321
|
-
// Timing-safe comparison
|
|
322
|
-
const sigBuf = Buffer.from(signatureB64);
|
|
323
|
-
const expectedBuf = Buffer.from(expectedSig);
|
|
324
|
-
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
|
325
|
-
throw new Error('Invalid signature');
|
|
326
|
-
}
|
|
498
|
+
await verifyServiceTokenSignature(token, jwtSecret);
|
|
499
|
+
verifyServiceTokenClaims(decoded, {
|
|
500
|
+
audience: expectedAudience,
|
|
501
|
+
issuer: expectedIssuer,
|
|
502
|
+
});
|
|
327
503
|
} catch (verifyError) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
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
|
+
};
|
|
334
536
|
if (onError) return onError(error);
|
|
335
|
-
return res.status(
|
|
537
|
+
return res.status(401).json(error);
|
|
336
538
|
}
|
|
337
539
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
+
};
|
|
344
550
|
if (onError) return onError(error);
|
|
345
|
-
return res.status(
|
|
551
|
+
return res.status(500).json(error);
|
|
346
552
|
}
|
|
347
553
|
|
|
348
554
|
// Check expiration — reject tokens at exact expiry second (use <=)
|
|
@@ -358,7 +564,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
358
564
|
}
|
|
359
565
|
|
|
360
566
|
// Validate required service token fields
|
|
361
|
-
|
|
567
|
+
const appId = decoded.appId;
|
|
568
|
+
if (!appId) {
|
|
362
569
|
if (optional) {
|
|
363
570
|
req.userId = null;
|
|
364
571
|
req.user = null;
|
|
@@ -370,18 +577,54 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
370
577
|
}
|
|
371
578
|
|
|
372
579
|
// Read delegated user ID from header
|
|
373
|
-
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
|
+
}
|
|
374
615
|
|
|
375
|
-
req.userId = oxyUserId || null;
|
|
376
|
-
req.user = oxyUserId ? ({ id: oxyUserId } as User) : null;
|
|
377
616
|
req.accessToken = token;
|
|
378
617
|
req.serviceApp = {
|
|
379
|
-
appId
|
|
618
|
+
appId,
|
|
380
619
|
appName: decoded.appName || 'unknown',
|
|
620
|
+
scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
|
|
381
621
|
};
|
|
382
622
|
|
|
383
623
|
if (debug) {
|
|
384
|
-
|
|
624
|
+
logger.debug(`[oxy.auth] Service token OK app=${decoded.appName} delegateUser=${oxyUserId || '(none)'}`, {
|
|
625
|
+
component: 'auth',
|
|
626
|
+
method: 'auth.serviceToken',
|
|
627
|
+
});
|
|
385
628
|
}
|
|
386
629
|
|
|
387
630
|
return next();
|
|
@@ -462,7 +705,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
462
705
|
}
|
|
463
706
|
|
|
464
707
|
if (debug) {
|
|
465
|
-
|
|
708
|
+
logger.debug(`[oxy.auth] OK user=${userId} session=${decoded.sessionId}`, {
|
|
709
|
+
component: 'auth',
|
|
710
|
+
method: 'auth',
|
|
711
|
+
});
|
|
466
712
|
}
|
|
467
713
|
|
|
468
714
|
// Process X-Acting-As header before proceeding
|
|
@@ -470,7 +716,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
470
716
|
return;
|
|
471
717
|
} catch (validationError) {
|
|
472
718
|
if (debug) {
|
|
473
|
-
|
|
719
|
+
logger.debug('[oxy.auth] Session validation failed', {
|
|
720
|
+
component: 'auth',
|
|
721
|
+
method: 'auth',
|
|
722
|
+
}, validationError);
|
|
474
723
|
}
|
|
475
724
|
|
|
476
725
|
if (optional) {
|
|
@@ -512,26 +761,49 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
512
761
|
if (fullUser) {
|
|
513
762
|
req.user = fullUser;
|
|
514
763
|
}
|
|
515
|
-
} catch {
|
|
516
|
-
//
|
|
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);
|
|
517
773
|
}
|
|
518
774
|
}
|
|
519
775
|
|
|
520
776
|
if (debug) {
|
|
521
|
-
|
|
777
|
+
logger.debug(`[oxy.auth] OK user=${userId} (no session)`, {
|
|
778
|
+
component: 'auth',
|
|
779
|
+
method: 'auth',
|
|
780
|
+
});
|
|
522
781
|
}
|
|
523
782
|
|
|
524
783
|
// Process X-Acting-As header before proceeding
|
|
525
784
|
if (await processActingAs()) next();
|
|
526
785
|
} catch (error) {
|
|
527
|
-
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
|
+
};
|
|
528
797
|
|
|
529
798
|
if (debug) {
|
|
530
|
-
|
|
799
|
+
logger.debug('[oxy.auth] Error', {
|
|
800
|
+
component: 'auth',
|
|
801
|
+
method: 'auth',
|
|
802
|
+
}, apiError);
|
|
531
803
|
}
|
|
532
804
|
|
|
533
805
|
if (onError) return onError(apiError);
|
|
534
|
-
return res.status(
|
|
806
|
+
return res.status(apiError.status).json(apiError);
|
|
535
807
|
}
|
|
536
808
|
};
|
|
537
809
|
}
|
|
@@ -562,10 +834,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
562
834
|
*/
|
|
563
835
|
authSocket(options: { debug?: boolean } = {}) {
|
|
564
836
|
const { debug = false } = options;
|
|
565
|
-
//
|
|
566
|
-
const oxyInstance = this as
|
|
837
|
+
// Cross-mixin method access typed via the same structural subset.
|
|
838
|
+
const oxyInstance = this as unknown as OxyAuthInstance;
|
|
567
839
|
|
|
568
|
-
return async (socket:
|
|
840
|
+
return async (socket: SocketLike, next: (err?: Error) => void) => {
|
|
569
841
|
try {
|
|
570
842
|
const token = socket.handshake?.auth?.token;
|
|
571
843
|
|
|
@@ -576,7 +848,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
576
848
|
let decoded: JwtPayload;
|
|
577
849
|
try {
|
|
578
850
|
decoded = jwtDecode<JwtPayload>(token);
|
|
579
|
-
} catch {
|
|
851
|
+
} catch (decodeError) {
|
|
852
|
+
if (debug) {
|
|
853
|
+
logger.debug('[oxy.authSocket] Token decode failed', {
|
|
854
|
+
component: 'auth',
|
|
855
|
+
method: 'authSocket',
|
|
856
|
+
}, decodeError);
|
|
857
|
+
}
|
|
580
858
|
return next(new Error('Invalid token'));
|
|
581
859
|
}
|
|
582
860
|
|
|
@@ -599,7 +877,13 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
599
877
|
if (!result || !result.valid) {
|
|
600
878
|
return next(new Error('Session invalid'));
|
|
601
879
|
}
|
|
602
|
-
} catch {
|
|
880
|
+
} catch (validateErr) {
|
|
881
|
+
if (debug) {
|
|
882
|
+
logger.debug('[oxy.authSocket] Session validation failed', {
|
|
883
|
+
component: 'auth',
|
|
884
|
+
method: 'authSocket',
|
|
885
|
+
}, validateErr);
|
|
886
|
+
}
|
|
603
887
|
return next(new Error('Session validation failed'));
|
|
604
888
|
}
|
|
605
889
|
}
|
|
@@ -616,13 +900,19 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
616
900
|
socket.user = { id: userId, userId, sessionId: decoded.sessionId };
|
|
617
901
|
|
|
618
902
|
if (debug) {
|
|
619
|
-
|
|
903
|
+
logger.debug(`[oxy.authSocket] OK user=${userId}`, {
|
|
904
|
+
component: 'auth',
|
|
905
|
+
method: 'authSocket',
|
|
906
|
+
});
|
|
620
907
|
}
|
|
621
908
|
|
|
622
909
|
next();
|
|
623
910
|
} catch (err) {
|
|
624
911
|
if (debug) {
|
|
625
|
-
|
|
912
|
+
logger.debug('[oxy.authSocket] Error', {
|
|
913
|
+
component: 'auth',
|
|
914
|
+
method: 'authSocket',
|
|
915
|
+
}, err);
|
|
626
916
|
}
|
|
627
917
|
next(new Error('Authentication error'));
|
|
628
918
|
}
|
|
@@ -636,7 +926,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
636
926
|
* @example
|
|
637
927
|
* ```typescript
|
|
638
928
|
* // Protect internal endpoints
|
|
639
|
-
* app.use('/internal', oxy.serviceAuth());
|
|
929
|
+
* app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.SERVICE_TOKEN_SECRET }));
|
|
640
930
|
*
|
|
641
931
|
* app.post('/internal/trigger', (req, res) => {
|
|
642
932
|
* console.log('Service app:', req.serviceApp);
|
|
@@ -644,10 +934,10 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
644
934
|
* });
|
|
645
935
|
* ```
|
|
646
936
|
*/
|
|
647
|
-
serviceAuth(options: { debug?: boolean; jwtSecret?: string } = {}) {
|
|
937
|
+
serviceAuth(options: { debug?: boolean; jwtSecret?: string; expectedIssuer?: string; expectedAudience?: string } = {}) {
|
|
648
938
|
const innerAuth = this.auth({ ...options });
|
|
649
939
|
|
|
650
|
-
return async (req:
|
|
940
|
+
return async (req: AuthReq, res: AuthRes, next: AuthNext) => {
|
|
651
941
|
await innerAuth(req, res, () => {
|
|
652
942
|
if (!req.serviceApp) {
|
|
653
943
|
return res.status(403).json({
|
|
@@ -660,6 +950,180 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
660
950
|
});
|
|
661
951
|
};
|
|
662
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
|
+
}
|
|
663
1012
|
};
|
|
664
1013
|
}
|
|
665
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
|
+
}
|