@oxy.so/core 1.3.1 → 1.5.0

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.
@@ -90,6 +90,44 @@ export interface ServiceApp {
90
90
  environment: OxyServiceEnvironment;
91
91
  }
92
92
 
93
+ /**
94
+ * Why a request's credential was refused, recorded on the request itself.
95
+ *
96
+ * The middleware answers a fixed, deliberately uninformative body to the
97
+ * client; this is the other half of that trade. A refusal is ALWAYS observable
98
+ * to the HOST: it is logged at `warn` with a stable `code`, handed to
99
+ * `onRefusal`, and left on `req.oxyAuthRefusal` for the host's own logs.
100
+ *
101
+ * It exists because the opposite cost was measured: Oxy served
102
+ * `{"keys":[]}` from `/.well-known/jwks.json` with no Ed25519 signing key
103
+ * bound, every service token failed `Oxy service-token key set is unavailable`,
104
+ * and on the OPTIONAL path that failure fell straight through to `next()`.
105
+ * The host then answered its own generic 401 and logged nothing at all, so the
106
+ * one fact that named the fault — an empty key set — existed nowhere. Hours.
107
+ *
108
+ * **Nothing here is ever sent to a client, and `reason` never contains the
109
+ * token, a signature, a secret or a session id.** It carries the SDK's own
110
+ * failure message (`kid` unknown, audience mismatch, key set unavailable) plus
111
+ * the ids already considered safe to log elsewhere in this middleware.
112
+ */
113
+ export interface OxyAuthRefusal {
114
+ /** Stable, greppable code. Matches the `code` a non-optional refusal answers. */
115
+ code: string;
116
+ /** Which credential lane refused. */
117
+ stage: 'token' | 'service-token' | 'session';
118
+ /** Human-readable cause. Never a token, signature, secret or session id. */
119
+ reason: string;
120
+ /** The status a NON-optional mount would have answered with. */
121
+ status: number;
122
+ /**
123
+ * `true` when the middleware was mounted with `optional: true`, so this
124
+ * refusal did not produce a response — the request continued unauthenticated
125
+ * and whatever the host does next (typically its own generic 401) is the
126
+ * only thing the client sees.
127
+ */
128
+ optional: boolean;
129
+ }
130
+
93
131
  /**
94
132
  * Expected JWT audience for tokens issued by the Oxy auth service.
95
133
  */
@@ -151,6 +189,17 @@ interface AuthMiddlewareOptions {
151
189
  loadUser?: boolean;
152
190
  /** Optional auth - attach user if token present but don't block (default: false) */
153
191
  optional?: boolean;
192
+ /**
193
+ * Called whenever a PRESENTED credential is refused, on the optional path as
194
+ * well as the blocking one. Runs before the response (if any) and after
195
+ * `req.oxyAuthRefusal` is set, so a host can raise its own alert or carry the
196
+ * code into its request log. Throwing from it is swallowed — an observer must
197
+ * not be able to turn a refusal into a 500.
198
+ *
199
+ * Refusal is about a credential that was offered and rejected: a request with
200
+ * NO `Authorization` header is not refused and does not reach this.
201
+ */
202
+ onRefusal?: (refusal: OxyAuthRefusal) => void;
154
203
  /**
155
204
  * Public JWKS endpoint used for Ed25519 service-token verification. Defaults
156
205
  * to `/.well-known/jwks.json` on this Oxy client's configured API origin.
@@ -355,6 +404,7 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
355
404
  const {
356
405
  debug = false,
357
406
  onError,
407
+ onRefusal,
358
408
  loadUser = false,
359
409
  optional = false,
360
410
  jwtSecret,
@@ -366,6 +416,50 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
366
416
  // composed OxyServices we know we have at runtime.
367
417
  const oxyInstance = this as unknown as OxyAuthInstance;
368
418
 
419
+ /**
420
+ * Make a refusal observable to the HOST, without telling the client
421
+ * anything it is not already told.
422
+ *
423
+ * Called at the top of every branch that rejects a PRESENTED credential,
424
+ * including the ones `optional` then swallows — those are the expensive
425
+ * ones, because they leave no trace anywhere else. The response bodies
426
+ * below are untouched by design: what a client sees does not change.
427
+ *
428
+ * `warn`, not `debug`: a debug-gated reason is only there for whoever
429
+ * already suspects this middleware, and the failure mode this exists for
430
+ * is the one where nobody does. The volume is bounded by refusals, and a
431
+ * host drowning in them has a fault worth the lines.
432
+ */
433
+ const recordRefusal = (
434
+ req: AuthReq,
435
+ refusal: { code: string; stage: OxyAuthRefusal['stage']; reason: string; status: number },
436
+ ): void => {
437
+ const recorded: OxyAuthRefusal = { ...refusal, optional };
438
+ req.oxyAuthRefusal = recorded;
439
+ logger.warn(`[oxy.auth] refused ${recorded.code}: ${recorded.reason}`, {
440
+ component: 'auth',
441
+ method: 'auth',
442
+ code: recorded.code,
443
+ stage: recorded.stage,
444
+ reason: recorded.reason,
445
+ status: recorded.status,
446
+ // Says whether the client will see this status or the host's own
447
+ // generic answer — the difference that made the JWKS outage invisible.
448
+ optional: recorded.optional,
449
+ path: req.path,
450
+ });
451
+ if (onRefusal) {
452
+ try {
453
+ onRefusal(recorded);
454
+ } catch (observerError) {
455
+ logger.warn('[oxy.auth] onRefusal observer threw', {
456
+ component: 'auth',
457
+ method: 'auth',
458
+ }, observerError);
459
+ }
460
+ }
461
+ };
462
+
369
463
  // Return an async middleware function
370
464
  return async (req: AuthReq, res: AuthRes, next: AuthNext) => {
371
465
  try {
@@ -405,6 +499,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
405
499
  try {
406
500
  decoded = jwtDecode<JwtPayload>(token);
407
501
  } catch (decodeError) {
502
+ recordRefusal(req, {
503
+ code: 'INVALID_TOKEN_FORMAT',
504
+ stage: 'token',
505
+ reason: 'Bearer credential is not a decodable JWT',
506
+ status: 401,
507
+ });
408
508
  if (debug) {
409
509
  logger.debug('[oxy.auth] Token decode failed', {
410
510
  component: 'auth',
@@ -453,6 +553,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
453
553
  if (
454
554
  verifyError instanceof ServiceTokenConfigurationError
455
555
  ) {
556
+ recordRefusal(req, {
557
+ code: 'SERVICE_TOKEN_NOT_CONFIGURED',
558
+ stage: 'service-token',
559
+ reason: verifyError.message,
560
+ status: 403,
561
+ });
456
562
  if (optional) {
457
563
  req.userId = null;
458
564
  req.user = null;
@@ -472,6 +578,20 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
472
578
  verifyError instanceof ServiceTokenSignatureError ||
473
579
  verifyError instanceof ServiceTokenClaimError
474
580
  ) {
581
+ const code = verifyError instanceof ServiceTokenClaimError
582
+ ? 'INVALID_SERVICE_TOKEN_CLAIMS'
583
+ : 'INVALID_SERVICE_TOKEN';
584
+ // The SDK's own message is the whole diagnostic value here —
585
+ // "signing key is unknown" (rotated/unpublished kid), "key set
586
+ // is unavailable" (JWKS empty, unreachable or malformed) and
587
+ // "audience mismatch" are three different outages that answer
588
+ // the client identically.
589
+ recordRefusal(req, {
590
+ code,
591
+ stage: 'service-token',
592
+ reason: `${verifyError.name}: ${verifyError.message}`,
593
+ status: 401,
594
+ });
475
595
  if (debug) {
476
596
  logger.debug('[oxy.auth] Service token rejected', {
477
597
  component: 'auth',
@@ -485,11 +605,6 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
485
605
  req.user = null;
486
606
  return next();
487
607
  }
488
- const code = verifyError instanceof ServiceTokenSignatureError
489
- ? 'INVALID_SERVICE_TOKEN'
490
- : verifyError instanceof ServiceTokenStructureError
491
- ? 'INVALID_SERVICE_TOKEN'
492
- : 'INVALID_SERVICE_TOKEN_CLAIMS';
493
608
  const error = {
494
609
  error: code,
495
610
  message: verifyError.message,
@@ -517,6 +632,14 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
517
632
  // Check expiration — reject tokens at exact expiry second (use <=)
518
633
  const now = Math.floor(Date.now() / 1000);
519
634
  if (!Number.isInteger(decoded.exp) || (decoded.exp as number) <= now) {
635
+ recordRefusal(req, {
636
+ code: 'TOKEN_EXPIRED',
637
+ stage: 'service-token',
638
+ reason: Number.isInteger(decoded.exp)
639
+ ? 'Service token expired'
640
+ : 'Service token has no integer exp claim',
641
+ status: 401,
642
+ });
520
643
  if (optional) {
521
644
  req.userId = null;
522
645
  req.user = null;
@@ -527,6 +650,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
527
650
  return res.status(401).json(error);
528
651
  }
529
652
  if (decoded.nbf !== undefined && (!Number.isInteger(decoded.nbf) || decoded.nbf > now)) {
653
+ recordRefusal(req, {
654
+ code: 'INVALID_SERVICE_TOKEN_CLAIMS',
655
+ stage: 'service-token',
656
+ reason: 'Service token is not yet valid (nbf)',
657
+ status: 401,
658
+ });
530
659
  const error = { error: 'INVALID_SERVICE_TOKEN_CLAIMS', message: 'Service token is not yet valid', code: 'INVALID_SERVICE_TOKEN_CLAIMS', status: 401 };
531
660
  if (onError) return onError(error);
532
661
  return res.status(401).json(error);
@@ -550,6 +679,22 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
550
679
  || !decoded.scopes.every((scope) => typeof scope === 'string' && scope.length > 0 && scope === scope.trim())
551
680
  || new Set(decoded.scopes).size !== decoded.scopes.length
552
681
  ) {
682
+ recordRefusal(req, {
683
+ code: 'INVALID_SERVICE_TOKEN',
684
+ stage: 'service-token',
685
+ // Names the field rather than its value: an exact-match claim
686
+ // failing on invisible whitespace reads as "present" in a log
687
+ // that only says which claims were missing.
688
+ reason: `Service token claims unusable (${[
689
+ !isExactNonEmptyServiceClaim(appId) ? 'appId' : null,
690
+ !isExactNonEmptyServiceClaim(decoded.appName) ? 'appName' : null,
691
+ !isExactNonEmptyServiceClaim(credentialId) ? 'credentialId' : null,
692
+ !isExactNonEmptyServiceClaim(ownerAccountId) ? 'ownerAccountId' : null,
693
+ !isOxyServiceEnvironment(environment) ? 'environment' : null,
694
+ !Array.isArray(decoded.scopes) ? 'scopes' : null,
695
+ ].filter((field) => field !== null).join(', ') || 'scopes'})`,
696
+ status: 401,
697
+ });
553
698
  if (optional) {
554
699
  req.userId = null;
555
700
  req.user = null;
@@ -564,6 +709,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
564
709
  const oxyUserIdRaw = req.headers['x-oxy-user-id'];
565
710
  const oxyUserId = isExactNonEmptyServiceClaim(oxyUserIdRaw) ? oxyUserIdRaw : null;
566
711
  if (oxyUserIdRaw !== undefined && oxyUserId === null) {
712
+ recordRefusal(req, {
713
+ code: 'INVALID_SERVICE_TOKEN_CLAIMS',
714
+ stage: 'service-token',
715
+ reason: 'X-Oxy-User-Id is not an exact non-empty id',
716
+ status: 401,
717
+ });
567
718
  const error = {
568
719
  error: 'INVALID_SERVICE_TOKEN_CLAIMS',
569
720
  message: 'Delegated user id must be an exact non-empty id',
@@ -588,6 +739,16 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
588
739
  appId,
589
740
  attemptedUserId: oxyUserId,
590
741
  });
742
+ // A verifier with no service credentials of its own cannot
743
+ // reach the verify endpoint at all, so `null` here can mean
744
+ // "no grant" OR "this host cannot ask". Both refuse, and both
745
+ // are worth naming on the request.
746
+ recordRefusal(req, {
747
+ code: 'SERVICE_ACTING_AS_UNAUTHORIZED',
748
+ stage: 'service-token',
749
+ reason: `No delegation grant for app ${appId} acting as ${oxyUserId} (or this verifier could not reach the grant check)`,
750
+ status: 403,
751
+ });
591
752
  const error = {
592
753
  error: 'SERVICE_ACTING_AS_UNAUTHORIZED',
593
754
  message: 'Service not authorized to act as this user',
@@ -636,6 +797,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
636
797
  // compared against the id the validated session resolves to.
637
798
  const claimedUserId = readStringClaim(decoded.userId) ?? readStringClaim(decoded.id);
638
799
  if (!claimedUserId) {
800
+ recordRefusal(req, {
801
+ code: 'INVALID_TOKEN_PAYLOAD',
802
+ stage: 'token',
803
+ reason: 'Token carries no usable user id claim',
804
+ status: 401,
805
+ });
639
806
  if (optional) {
640
807
  req.userId = null;
641
808
  req.user = null;
@@ -655,6 +822,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
655
822
  // Check token expiration locally first (fast path)
656
823
  // Reject tokens at exact expiry second (use <=)
657
824
  if (decoded.exp && decoded.exp <= Math.floor(Date.now() / 1000)) {
825
+ recordRefusal(req, {
826
+ code: 'TOKEN_EXPIRED',
827
+ stage: 'token',
828
+ reason: 'User access token expired',
829
+ status: 401,
830
+ });
658
831
  if (optional) {
659
832
  req.userId = null;
660
833
  req.user = null;
@@ -678,6 +851,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
678
851
  // required this.
679
852
  const sessionId = readStringClaim(decoded.sessionId);
680
853
  if (!sessionId) {
854
+ recordRefusal(req, {
855
+ code: 'SESSION_REQUIRED',
856
+ stage: 'token',
857
+ reason: 'User access token is not bound to a session',
858
+ status: 401,
859
+ });
681
860
  if (optional) {
682
861
  req.userId = null;
683
862
  req.user = null;
@@ -702,6 +881,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
702
881
  });
703
882
 
704
883
  if (!validationResult || !validationResult.valid || !validationResult.user) {
884
+ recordRefusal(req, {
885
+ code: 'INVALID_SESSION',
886
+ stage: 'session',
887
+ reason: 'Session is invalid, revoked or expired',
888
+ status: 401,
889
+ });
705
890
  if (optional) {
706
891
  req.userId = null;
707
892
  req.user = null;
@@ -721,6 +906,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
721
906
  // The session — not the token — is the source of truth for identity.
722
907
  const validatedUserId = getUserIdentityId(validationResult.user);
723
908
  if (!validatedUserId) {
909
+ recordRefusal(req, {
910
+ code: 'INVALID_SESSION',
911
+ stage: 'session',
912
+ reason: 'Session did not resolve to a usable identity',
913
+ status: 401,
914
+ });
724
915
  if (optional) {
725
916
  req.userId = null;
726
917
  req.user = null;
@@ -747,6 +938,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
747
938
  claimedUserId,
748
939
  validatedUserId,
749
940
  });
941
+ recordRefusal(req, {
942
+ code: 'SESSION_USER_MISMATCH',
943
+ stage: 'session',
944
+ reason: 'Token user claim does not own the presented session',
945
+ status: 401,
946
+ });
750
947
 
751
948
  if (optional) {
752
949
  req.userId = null;
@@ -780,6 +977,15 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
780
977
 
781
978
  return next();
782
979
  } catch (validationError) {
980
+ recordRefusal(req, {
981
+ code: 'SESSION_VALIDATION_ERROR',
982
+ stage: 'session',
983
+ // Name and transport code only. The message of a failed
984
+ // `validateSession` can carry the request URL, and that URL
985
+ // contains the session id — which is a credential.
986
+ reason: `Session validation call failed (${describeErrorSafely(validationError)})`,
987
+ status: 401,
988
+ });
783
989
  if (debug) {
784
990
  logger.debug('[oxy.auth] Session validation failed', {
785
991
  component: 'auth',
@@ -1251,6 +1457,20 @@ async function verifyServiceTokenSignature(
1251
1457
  * those values out of URL construction and identity comparison, so an
1252
1458
  * unexpected shape becomes a 401 rather than a stringified surprise.
1253
1459
  */
1460
+ /**
1461
+ * A short, log-safe description of a thrown value: its class name and, when the
1462
+ * transport attached one, its `code` (`ECONNREFUSED`, `ETIMEDOUT`, `ENOTFOUND`
1463
+ * …). Never the message — a failed request's message can quote the URL it was
1464
+ * made against, and a session-validation URL contains the session id.
1465
+ */
1466
+ function describeErrorSafely(error: unknown): string {
1467
+ if (!(error instanceof Error)) return typeof error;
1468
+ const code = (error as { code?: unknown }).code;
1469
+ return typeof code === 'string' && /^[A-Za-z0-9_]{1,40}$/.test(code)
1470
+ ? `${error.name}: ${code}`
1471
+ : error.name;
1472
+ }
1473
+
1254
1474
  function readStringClaim(value: unknown): string | null {
1255
1475
  return typeof value === 'string' && value.length > 0 ? value : null;
1256
1476
  }
@@ -1315,6 +1535,7 @@ interface AuthReq {
1315
1535
  sessionId?: string | null;
1316
1536
  serviceApp?: ServiceApp;
1317
1537
  serviceActingAs?: { userId: string; scopes: string[] };
1538
+ oxyAuthRefusal?: OxyAuthRefusal;
1318
1539
  }
1319
1540
 
1320
1541
  interface AuthRes {