@oxy.so/core 1.4.0 → 1.5.1

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.
@@ -209,10 +209,51 @@ export function OxyServicesUtilityMixin(Base) {
209
209
  * @returns Express middleware function
210
210
  */
211
211
  auth(options = {}) {
212
- const { debug = false, onError, loadUser = false, optional = false, jwtSecret, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', this.getBaseURL()).toString(), expectedIssuer = OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
212
+ const { debug = false, onError, onRefusal, loadUser = false, optional = false, jwtSecret, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', this.getBaseURL()).toString(), expectedIssuer = OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
213
213
  // Cross-mixin method access: typed as a structural subset of the
214
214
  // composed OxyServices we know we have at runtime.
215
215
  const oxyInstance = this;
216
+ /**
217
+ * Make a refusal observable to the HOST, without telling the client
218
+ * anything it is not already told.
219
+ *
220
+ * Called at the top of every branch that rejects a PRESENTED credential,
221
+ * including the ones `optional` then swallows — those are the expensive
222
+ * ones, because they leave no trace anywhere else. The response bodies
223
+ * below are untouched by design: what a client sees does not change.
224
+ *
225
+ * `warn`, not `debug`: a debug-gated reason is only there for whoever
226
+ * already suspects this middleware, and the failure mode this exists for
227
+ * is the one where nobody does. The volume is bounded by refusals, and a
228
+ * host drowning in them has a fault worth the lines.
229
+ */
230
+ const recordRefusal = (req, refusal) => {
231
+ const recorded = { ...refusal, optional };
232
+ req.oxyAuthRefusal = recorded;
233
+ logger.warn(`[oxy.auth] refused ${recorded.code}: ${recorded.reason}`, {
234
+ component: 'auth',
235
+ method: 'auth',
236
+ code: recorded.code,
237
+ stage: recorded.stage,
238
+ reason: recorded.reason,
239
+ status: recorded.status,
240
+ // Says whether the client will see this status or the host's own
241
+ // generic answer — the difference that made the JWKS outage invisible.
242
+ optional: recorded.optional,
243
+ path: req.path,
244
+ });
245
+ if (onRefusal) {
246
+ try {
247
+ onRefusal(recorded);
248
+ }
249
+ catch (observerError) {
250
+ logger.warn('[oxy.auth] onRefusal observer threw', {
251
+ component: 'auth',
252
+ method: 'auth',
253
+ }, observerError);
254
+ }
255
+ }
256
+ };
216
257
  // Return an async middleware function
217
258
  return async (req, res, next) => {
218
259
  try {
@@ -250,6 +291,12 @@ export function OxyServicesUtilityMixin(Base) {
250
291
  decoded = jwtDecode(token);
251
292
  }
252
293
  catch (decodeError) {
294
+ recordRefusal(req, {
295
+ code: 'INVALID_TOKEN_FORMAT',
296
+ stage: 'token',
297
+ reason: 'Bearer credential is not a decodable JWT',
298
+ status: 401,
299
+ });
253
300
  if (debug) {
254
301
  logger.debug('[oxy.auth] Token decode failed', {
255
302
  component: 'auth',
@@ -296,6 +343,12 @@ export function OxyServicesUtilityMixin(Base) {
296
343
  // else (e.g. Node crypto failing to load on a misconfigured host)
297
344
  // genuinely IS a 500.
298
345
  if (verifyError instanceof ServiceTokenConfigurationError) {
346
+ recordRefusal(req, {
347
+ code: 'SERVICE_TOKEN_NOT_CONFIGURED',
348
+ stage: 'service-token',
349
+ reason: verifyError.message,
350
+ status: 403,
351
+ });
299
352
  if (optional) {
300
353
  req.userId = null;
301
354
  req.user = null;
@@ -314,6 +367,20 @@ export function OxyServicesUtilityMixin(Base) {
314
367
  if (verifyError instanceof ServiceTokenStructureError ||
315
368
  verifyError instanceof ServiceTokenSignatureError ||
316
369
  verifyError instanceof ServiceTokenClaimError) {
370
+ const code = verifyError instanceof ServiceTokenClaimError
371
+ ? 'INVALID_SERVICE_TOKEN_CLAIMS'
372
+ : 'INVALID_SERVICE_TOKEN';
373
+ // The SDK's own message is the whole diagnostic value here —
374
+ // "signing key is unknown" (rotated/unpublished kid), "key set
375
+ // is unavailable" (JWKS empty, unreachable or malformed) and
376
+ // "audience mismatch" are three different outages that answer
377
+ // the client identically.
378
+ recordRefusal(req, {
379
+ code,
380
+ stage: 'service-token',
381
+ reason: `${verifyError.name}: ${verifyError.message}`,
382
+ status: 401,
383
+ });
317
384
  if (debug) {
318
385
  logger.debug('[oxy.auth] Service token rejected', {
319
386
  component: 'auth',
@@ -327,11 +394,6 @@ export function OxyServicesUtilityMixin(Base) {
327
394
  req.user = null;
328
395
  return next();
329
396
  }
330
- const code = verifyError instanceof ServiceTokenSignatureError
331
- ? 'INVALID_SERVICE_TOKEN'
332
- : verifyError instanceof ServiceTokenStructureError
333
- ? 'INVALID_SERVICE_TOKEN'
334
- : 'INVALID_SERVICE_TOKEN_CLAIMS';
335
397
  const error = {
336
398
  error: code,
337
399
  message: verifyError.message,
@@ -359,6 +421,14 @@ export function OxyServicesUtilityMixin(Base) {
359
421
  // Check expiration — reject tokens at exact expiry second (use <=)
360
422
  const now = Math.floor(Date.now() / 1000);
361
423
  if (!Number.isInteger(decoded.exp) || decoded.exp <= now) {
424
+ recordRefusal(req, {
425
+ code: 'TOKEN_EXPIRED',
426
+ stage: 'service-token',
427
+ reason: Number.isInteger(decoded.exp)
428
+ ? 'Service token expired'
429
+ : 'Service token has no integer exp claim',
430
+ status: 401,
431
+ });
362
432
  if (optional) {
363
433
  req.userId = null;
364
434
  req.user = null;
@@ -370,6 +440,12 @@ export function OxyServicesUtilityMixin(Base) {
370
440
  return res.status(401).json(error);
371
441
  }
372
442
  if (decoded.nbf !== undefined && (!Number.isInteger(decoded.nbf) || decoded.nbf > now)) {
443
+ recordRefusal(req, {
444
+ code: 'INVALID_SERVICE_TOKEN_CLAIMS',
445
+ stage: 'service-token',
446
+ reason: 'Service token is not yet valid (nbf)',
447
+ status: 401,
448
+ });
373
449
  const error = { error: 'INVALID_SERVICE_TOKEN_CLAIMS', message: 'Service token is not yet valid', code: 'INVALID_SERVICE_TOKEN_CLAIMS', status: 401 };
374
450
  if (onError)
375
451
  return onError(error);
@@ -391,6 +467,22 @@ export function OxyServicesUtilityMixin(Base) {
391
467
  || !Array.isArray(decoded.scopes)
392
468
  || !decoded.scopes.every((scope) => typeof scope === 'string' && scope.length > 0 && scope === scope.trim())
393
469
  || new Set(decoded.scopes).size !== decoded.scopes.length) {
470
+ recordRefusal(req, {
471
+ code: 'INVALID_SERVICE_TOKEN',
472
+ stage: 'service-token',
473
+ // Names the field rather than its value: an exact-match claim
474
+ // failing on invisible whitespace reads as "present" in a log
475
+ // that only says which claims were missing.
476
+ reason: `Service token claims unusable (${[
477
+ !isExactNonEmptyServiceClaim(appId) ? 'appId' : null,
478
+ !isExactNonEmptyServiceClaim(decoded.appName) ? 'appName' : null,
479
+ !isExactNonEmptyServiceClaim(credentialId) ? 'credentialId' : null,
480
+ !isExactNonEmptyServiceClaim(ownerAccountId) ? 'ownerAccountId' : null,
481
+ !isOxyServiceEnvironment(environment) ? 'environment' : null,
482
+ !Array.isArray(decoded.scopes) ? 'scopes' : null,
483
+ ].filter((field) => field !== null).join(', ') || 'scopes'})`,
484
+ status: 401,
485
+ });
394
486
  if (optional) {
395
487
  req.userId = null;
396
488
  req.user = null;
@@ -405,6 +497,12 @@ export function OxyServicesUtilityMixin(Base) {
405
497
  const oxyUserIdRaw = req.headers['x-oxy-user-id'];
406
498
  const oxyUserId = isExactNonEmptyServiceClaim(oxyUserIdRaw) ? oxyUserIdRaw : null;
407
499
  if (oxyUserIdRaw !== undefined && oxyUserId === null) {
500
+ recordRefusal(req, {
501
+ code: 'INVALID_SERVICE_TOKEN_CLAIMS',
502
+ stage: 'service-token',
503
+ reason: 'X-Oxy-User-Id is not an exact non-empty id',
504
+ status: 401,
505
+ });
408
506
  const error = {
409
507
  error: 'INVALID_SERVICE_TOKEN_CLAIMS',
410
508
  message: 'Delegated user id must be an exact non-empty id',
@@ -429,6 +527,16 @@ export function OxyServicesUtilityMixin(Base) {
429
527
  appId,
430
528
  attemptedUserId: oxyUserId,
431
529
  });
530
+ // A verifier with no service credentials of its own cannot
531
+ // reach the verify endpoint at all, so `null` here can mean
532
+ // "no grant" OR "this host cannot ask". Both refuse, and both
533
+ // are worth naming on the request.
534
+ recordRefusal(req, {
535
+ code: 'SERVICE_ACTING_AS_UNAUTHORIZED',
536
+ stage: 'service-token',
537
+ reason: `No delegation grant for app ${appId} acting as ${oxyUserId} (or this verifier could not reach the grant check)`,
538
+ status: 403,
539
+ });
432
540
  const error = {
433
541
  error: 'SERVICE_ACTING_AS_UNAUTHORIZED',
434
542
  message: 'Service not authorized to act as this user',
@@ -474,6 +582,12 @@ export function OxyServicesUtilityMixin(Base) {
474
582
  // compared against the id the validated session resolves to.
475
583
  const claimedUserId = readStringClaim(decoded.userId) ?? readStringClaim(decoded.id);
476
584
  if (!claimedUserId) {
585
+ recordRefusal(req, {
586
+ code: 'INVALID_TOKEN_PAYLOAD',
587
+ stage: 'token',
588
+ reason: 'Token carries no usable user id claim',
589
+ status: 401,
590
+ });
477
591
  if (optional) {
478
592
  req.userId = null;
479
593
  req.user = null;
@@ -492,6 +606,12 @@ export function OxyServicesUtilityMixin(Base) {
492
606
  // Check token expiration locally first (fast path)
493
607
  // Reject tokens at exact expiry second (use <=)
494
608
  if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
609
+ recordRefusal(req, {
610
+ code: 'TOKEN_EXPIRED',
611
+ stage: 'token',
612
+ reason: 'User access token expired',
613
+ status: 401,
614
+ });
495
615
  if (optional) {
496
616
  req.userId = null;
497
617
  req.user = null;
@@ -514,6 +634,12 @@ export function OxyServicesUtilityMixin(Base) {
514
634
  // required this.
515
635
  const sessionId = readStringClaim(decoded.sessionId);
516
636
  if (!sessionId) {
637
+ recordRefusal(req, {
638
+ code: 'SESSION_REQUIRED',
639
+ stage: 'token',
640
+ reason: 'User access token is not bound to a session',
641
+ status: 401,
642
+ });
517
643
  if (optional) {
518
644
  req.userId = null;
519
645
  req.user = null;
@@ -536,6 +662,12 @@ export function OxyServicesUtilityMixin(Base) {
536
662
  useHeaderValidation: true,
537
663
  });
538
664
  if (!validationResult || !validationResult.valid || !validationResult.user) {
665
+ recordRefusal(req, {
666
+ code: 'INVALID_SESSION',
667
+ stage: 'session',
668
+ reason: 'Session is invalid, revoked or expired',
669
+ status: 401,
670
+ });
539
671
  if (optional) {
540
672
  req.userId = null;
541
673
  req.user = null;
@@ -554,6 +686,12 @@ export function OxyServicesUtilityMixin(Base) {
554
686
  // The session — not the token — is the source of truth for identity.
555
687
  const validatedUserId = getUserIdentityId(validationResult.user);
556
688
  if (!validatedUserId) {
689
+ recordRefusal(req, {
690
+ code: 'INVALID_SESSION',
691
+ stage: 'session',
692
+ reason: 'Session did not resolve to a usable identity',
693
+ status: 401,
694
+ });
557
695
  if (optional) {
558
696
  req.userId = null;
559
697
  req.user = null;
@@ -579,6 +717,12 @@ export function OxyServicesUtilityMixin(Base) {
579
717
  claimedUserId,
580
718
  validatedUserId,
581
719
  });
720
+ recordRefusal(req, {
721
+ code: 'SESSION_USER_MISMATCH',
722
+ stage: 'session',
723
+ reason: 'Token user claim does not own the presented session',
724
+ status: 401,
725
+ });
582
726
  if (optional) {
583
727
  req.userId = null;
584
728
  req.user = null;
@@ -609,6 +753,15 @@ export function OxyServicesUtilityMixin(Base) {
609
753
  return next();
610
754
  }
611
755
  catch (validationError) {
756
+ recordRefusal(req, {
757
+ code: 'SESSION_VALIDATION_ERROR',
758
+ stage: 'session',
759
+ // Name and transport code only. The message of a failed
760
+ // `validateSession` can carry the request URL, and that URL
761
+ // contains the session id — which is a credential.
762
+ reason: `Session validation call failed (${describeErrorSafely(validationError)})`,
763
+ status: 401,
764
+ });
612
765
  if (debug) {
613
766
  logger.debug('[oxy.auth] Session validation failed', {
614
767
  component: 'auth',
@@ -1034,6 +1187,20 @@ async function verifyServiceTokenSignature(token, options) {
1034
1187
  * those values out of URL construction and identity comparison, so an
1035
1188
  * unexpected shape becomes a 401 rather than a stringified surprise.
1036
1189
  */
1190
+ /**
1191
+ * A short, log-safe description of a thrown value: its class name and, when the
1192
+ * transport attached one, its `code` (`ECONNREFUSED`, `ETIMEDOUT`, `ENOTFOUND`
1193
+ * …). Never the message — a failed request's message can quote the URL it was
1194
+ * made against, and a session-validation URL contains the session id.
1195
+ */
1196
+ function describeErrorSafely(error) {
1197
+ if (!(error instanceof Error))
1198
+ return typeof error;
1199
+ const code = error.code;
1200
+ return typeof code === 'string' && /^[A-Za-z0-9_]{1,40}$/.test(code)
1201
+ ? `${error.name}: ${code}`
1202
+ : error.name;
1203
+ }
1037
1204
  function readStringClaim(value) {
1038
1205
  return typeof value === 'string' && value.length > 0 ? value : null;
1039
1206
  }
@@ -1,3 +1,4 @@
1
+ import { logger } from '../logger/index.js';
1
2
  import { OXY_SERVICE_ENVIRONMENTS } from '../utils/oxyServiceEnvironment.js';
2
3
  export { OXY_SERVICE_ENVIRONMENTS };
3
4
  function normalizeId(value) {
@@ -106,9 +107,36 @@ export function getRequiredOxyUserId(req) {
106
107
  }
107
108
  return userId;
108
109
  }
110
+ /**
111
+ * Why the credential on this request was refused, or `null` when none was
112
+ * presented (or it was accepted).
113
+ *
114
+ * Set by `oxy.auth()` — including the `optional: true` mount the composed
115
+ * helpers use, where a refusal otherwise leaves no trace: the request simply
116
+ * arrives unauthenticated and the host answers its own generic 401. Hosts log
117
+ * this beside their own 401; it is diagnostic and **must not be put into a
118
+ * response body**, which is exactly what it is kept out of.
119
+ */
120
+ export function getOxyAuthRefusal(req) {
121
+ return req.oxyAuthRefusal ?? null;
122
+ }
109
123
  export function requireOxyAuth(req, res, next) {
110
124
  const userId = getOxyUserId(req);
111
125
  if (!userId) {
126
+ // The body stays exactly as generic as it was. The LOG does not: this 401
127
+ // is the one a caller sees when the optional resolver already refused a
128
+ // real credential, and printing that code here is what turns "Homiio got
129
+ // 401, Alia logged nothing" into one greppable line.
130
+ const refusal = getOxyAuthRefusal(req);
131
+ if (refusal) {
132
+ logger.warn(`[oxy.auth] 401 after refusal ${refusal.code}: ${refusal.reason}`, {
133
+ component: 'auth',
134
+ method: 'requireOxyAuth',
135
+ code: refusal.code,
136
+ stage: refusal.stage,
137
+ reason: refusal.reason,
138
+ });
139
+ }
112
140
  res.status(401).json({
113
141
  error: 'Unauthorized',
114
142
  message: 'Authentication required',
@@ -14,7 +14,7 @@
14
14
  * app.use(createOxyRateLimit(oxy, { store: redisStore }));
15
15
  * ```
16
16
  */
17
- export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth.js';
17
+ export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyAuthRefusal, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth.js';
18
18
  export { createOxyRateLimit } from './rateLimit.js';
19
19
  // SSRF-safe upstream fetch + URL validation (Node-only).
20
20
  export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch.js';