@oxy.so/core 7.10.4 → 7.10.6

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.
@@ -10,6 +10,7 @@ exports.SessionApi = void 0;
10
10
  */
11
11
  const jwt_decode_1 = require("jwt-decode");
12
12
  const userIdentity_1 = require("../utils/userIdentity");
13
+ const OxyServices_errors_1 = require("../OxyServices.errors");
13
14
  class SessionApi {
14
15
  constructor(ctx) {
15
16
  this.ctx = ctx;
@@ -144,13 +145,31 @@ class SessionApi {
144
145
  * validates the session it was issued for.
145
146
  */
146
147
  async validate(sessionId, options = {}) {
148
+ if ('liveSession' in options)
149
+ throw new OxyServices_errors_1.OxyApiError('Live session validation requires OxyServer', {
150
+ status: 503,
151
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
152
+ });
153
+ return this.validateRequest(sessionId, options);
154
+ }
155
+ /** Server implementations use this transport after enforcing their own contract. */
156
+ async validateRequest(sessionId, options) {
147
157
  try {
158
+ if (options.liveSession && !options.accessToken)
159
+ throw new OxyServices_errors_1.OxyApiError('Live session authority requires a bearer', {
160
+ status: 503,
161
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
162
+ });
148
163
  const validation = options.accessToken
149
164
  ? await this.ctx.request('GET', `/session/validate-header/${encodeURIComponent(sessionId)}`, undefined, {
150
165
  cache: false,
151
166
  skipAuth: true,
167
+ ...(options.liveSession ? { deduplicate: false, retry: false } : {}),
152
168
  headers: {
153
169
  Authorization: `Bearer ${options.accessToken}`,
170
+ ...(options.liveSession
171
+ ? { 'x-oxy-session-authority': 'live-v1', 'cache-control': 'no-store' }
172
+ : {}),
154
173
  ...(options.deviceFingerprint
155
174
  ? { 'x-device-fingerprint': options.deviceFingerprint }
156
175
  : {}),
@@ -162,9 +181,36 @@ class SessionApi {
162
181
  : {}),
163
182
  ...(options.useHeaderValidation ? { useHeaderValidation: 'true' } : {}),
164
183
  }, { cache: false });
184
+ if (options.liveSession &&
185
+ (validation.liveSessionAuthority !== 'session-row-v1' ||
186
+ validation.valid !== true ||
187
+ validation.sessionId !== sessionId ||
188
+ typeof validation.expiresAt !== 'string' ||
189
+ !Number.isFinite(Date.parse(validation.expiresAt)) ||
190
+ Date.parse(validation.expiresAt) <= Date.now() ||
191
+ typeof validation.lastActivity !== 'string' ||
192
+ !Number.isFinite(Date.parse(validation.lastActivity))))
193
+ throw new OxyServices_errors_1.OxyApiError('Live session authority is unavailable', {
194
+ status: 503,
195
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
196
+ });
165
197
  return { ...validation, user: (0, userIdentity_1.normalizeUserIdentity)(validation.user) };
166
198
  }
167
199
  catch (error) {
200
+ if (options.liveSession) {
201
+ const candidate = error;
202
+ const invalid = candidate?.status === 401 && candidate.code === 'OXY_LIVE_SESSION_INVALID_V1';
203
+ const denied = candidate?.status === 403 && candidate.code === 'OXY_LIVE_SESSION_POLICY_V1';
204
+ if (invalid)
205
+ this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
206
+ if (invalid || denied)
207
+ throw error;
208
+ throw new OxyServices_errors_1.OxyApiError('Live session authority is unavailable', {
209
+ status: 503,
210
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
211
+ cause: error,
212
+ });
213
+ }
168
214
  // The session is gone: drop any user cached for it (#196).
169
215
  this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
170
216
  throw error;
@@ -138,7 +138,7 @@ function createOxyMiddleware(host) {
138
138
  */
139
139
  function auth(options = {}) {
140
140
  const { debug = false, onError, onRefusal, loadUser = false, optional = false, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', host.baseURL).toString(), expectedIssuer = exports.OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
141
- const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer);
141
+ const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer || options.liveSession);
142
142
  /**
143
143
  * Make a refusal observable to the HOST, without telling the client
144
144
  * anything it is not already told.
@@ -247,6 +247,17 @@ function createOxyMiddleware(host) {
247
247
  // Handle service tokens (internal service-to-service auth)
248
248
  // Service tokens are stateless JWTs with type: 'service' — requires signature verification
249
249
  if (decoded.type === 'service') {
250
+ if (options.liveSession) {
251
+ const error = {
252
+ error: 'SESSION_REQUIRED',
253
+ message: 'A live user session is required',
254
+ code: 'SESSION_REQUIRED',
255
+ status: 403,
256
+ };
257
+ if (onError)
258
+ return onError(error);
259
+ return res.status(403).json(error);
260
+ }
250
261
  // Verify JWT signature, then audience / issuer / type / appId claims.
251
262
  //
252
263
  // Signature verification uses Node crypto rather than
@@ -659,6 +670,7 @@ function createOxyMiddleware(host) {
659
670
  accessToken: token,
660
671
  supervisedAction: options.supervisedAction,
661
672
  fresh: options.freshSession,
673
+ ...(options.liveSession ? { liveSession: true } : {}),
662
674
  });
663
675
  if (!validationResult || !validationResult.valid || !validationResult.user) {
664
676
  recordRefusal(req, {
@@ -809,7 +821,12 @@ function createOxyMiddleware(host) {
809
821
  return onError(refusal);
810
822
  return res.status(403).json(refusal);
811
823
  }
812
- const validationStatus = strictPresentedBearer && (0, errorUtils_1.extractErrorStatus)(validationError) !== 401 ? 503 : 401;
824
+ // A temporary authority failure is not a revoked session or an anonymous visitor.
825
+ const authorityUnavailable = (0, errorUtils_1.extractErrorStatus)(validationError) === 503;
826
+ const validationStatus = authorityUnavailable ||
827
+ (strictPresentedBearer && (0, errorUtils_1.extractErrorStatus)(validationError) !== 401)
828
+ ? 503
829
+ : 401;
813
830
  recordRefusal(req, {
814
831
  code: 'SESSION_VALIDATION_ERROR',
815
832
  stage: 'session',
@@ -825,7 +842,7 @@ function createOxyMiddleware(host) {
825
842
  method: 'auth',
826
843
  }, validationError);
827
844
  }
828
- if (optional && !strictPresentedBearer) {
845
+ if (optional && !strictPresentedBearer && !authorityUnavailable) {
829
846
  req.userId = null;
830
847
  req.user = null;
831
848
  return next();
@@ -19,10 +19,11 @@ exports.ServerSessionApi = exports.SESSION_VALIDATION_CACHE_TTL_MS = void 0;
19
19
  * bearer) — never the raw token — for {@link SESSION_VALIDATION_CACHE_TTL_MS}
20
20
  * (30 s) by default, and never past the token's own `exp`.
21
21
  *
22
- * THE SECURITY CONTRACT: the cache never keeps a revoked session alive. Oxy
23
- * announces every session it ends on its service-event feed
24
- * (`./serviceEvents.ts`; `@oxy.so/contracts` `sessionRevocation.ts`), and the
25
- * cache is used ONLY while that feed is live:
22
+ * Oxy announces ended sessions on its service-event feed (`./serviceEvents.ts`;
23
+ * `@oxy.so/contracts` `sessionRevocation.ts`), and the cache is used ONLY while
24
+ * that feed is live. Feed health alone cannot prove that every event was
25
+ * delivered. Routes requiring a fresh session-row read can opt in to
26
+ * `liveSession`; see `ServerSessionValidateOptions` for its scope.
26
27
  *
27
28
  * - no feed (this process has no service token: no credential and no attested
28
29
  * workload, as in a test or a local checkout), a feed not yet connected, a
@@ -49,6 +50,7 @@ const node_crypto_1 = require("node:crypto");
49
50
  const contracts_1 = require("@oxy.so/contracts");
50
51
  const jwt_decode_1 = require("jwt-decode");
51
52
  const session_1 = require("../api/session");
53
+ const OxyServices_errors_1 = require("../OxyServices.errors");
52
54
  const userIdentity_1 = require("../utils/userIdentity");
53
55
  /** Default lifetime of one cached validation. */
54
56
  exports.SESSION_VALIDATION_CACHE_TTL_MS = 30000;
@@ -81,7 +83,29 @@ class ServerSessionApi extends session_1.SessionApi {
81
83
  this.revoked = new Map();
82
84
  }
83
85
  async validate(sessionId, options = {}) {
84
- const { supervisedAction, fresh, ...rest } = options;
86
+ const { supervisedAction, fresh, liveSession, ...rest } = options;
87
+ if (liveSession) {
88
+ if (supervisedAction)
89
+ throw new OxyServices_errors_1.OxyApiError('Live supervised session authority is unavailable', {
90
+ status: 503,
91
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
92
+ });
93
+ try {
94
+ const result = await this.validateRequest(sessionId, { ...rest, liveSession: true });
95
+ const actor = contracts_1.accountActorChainSchema.safeParse(result.actor);
96
+ if (!actor.success || actor.data.effectiveAccountId !== result.user.id)
97
+ throw new OxyServices_errors_1.OxyApiError('Invalid live session authority response', {
98
+ status: 503,
99
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
100
+ });
101
+ return result;
102
+ }
103
+ catch (error) {
104
+ if (error?.status === 401)
105
+ this.refuse(sessionId);
106
+ throw error;
107
+ }
108
+ }
85
109
  if (supervisedAction)
86
110
  return this.validateSupervised(sessionId, supervisedAction, rest);
87
111
  if (!rest.accessToken || rest.deviceFingerprint || this.validationTtlMs <= 0) {
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { jwtDecode } from 'jwt-decode';
9
9
  import { normalizeUserIdentity } from '../utils/userIdentity.js';
10
+ import { OxyApiError } from '../OxyServices.errors.js';
10
11
  export class SessionApi {
11
12
  constructor(ctx) {
12
13
  this.ctx = ctx;
@@ -141,13 +142,31 @@ export class SessionApi {
141
142
  * validates the session it was issued for.
142
143
  */
143
144
  async validate(sessionId, options = {}) {
145
+ if ('liveSession' in options)
146
+ throw new OxyApiError('Live session validation requires OxyServer', {
147
+ status: 503,
148
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
149
+ });
150
+ return this.validateRequest(sessionId, options);
151
+ }
152
+ /** Server implementations use this transport after enforcing their own contract. */
153
+ async validateRequest(sessionId, options) {
144
154
  try {
155
+ if (options.liveSession && !options.accessToken)
156
+ throw new OxyApiError('Live session authority requires a bearer', {
157
+ status: 503,
158
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
159
+ });
145
160
  const validation = options.accessToken
146
161
  ? await this.ctx.request('GET', `/session/validate-header/${encodeURIComponent(sessionId)}`, undefined, {
147
162
  cache: false,
148
163
  skipAuth: true,
164
+ ...(options.liveSession ? { deduplicate: false, retry: false } : {}),
149
165
  headers: {
150
166
  Authorization: `Bearer ${options.accessToken}`,
167
+ ...(options.liveSession
168
+ ? { 'x-oxy-session-authority': 'live-v1', 'cache-control': 'no-store' }
169
+ : {}),
151
170
  ...(options.deviceFingerprint
152
171
  ? { 'x-device-fingerprint': options.deviceFingerprint }
153
172
  : {}),
@@ -159,9 +178,36 @@ export class SessionApi {
159
178
  : {}),
160
179
  ...(options.useHeaderValidation ? { useHeaderValidation: 'true' } : {}),
161
180
  }, { cache: false });
181
+ if (options.liveSession &&
182
+ (validation.liveSessionAuthority !== 'session-row-v1' ||
183
+ validation.valid !== true ||
184
+ validation.sessionId !== sessionId ||
185
+ typeof validation.expiresAt !== 'string' ||
186
+ !Number.isFinite(Date.parse(validation.expiresAt)) ||
187
+ Date.parse(validation.expiresAt) <= Date.now() ||
188
+ typeof validation.lastActivity !== 'string' ||
189
+ !Number.isFinite(Date.parse(validation.lastActivity))))
190
+ throw new OxyApiError('Live session authority is unavailable', {
191
+ status: 503,
192
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
193
+ });
162
194
  return { ...validation, user: normalizeUserIdentity(validation.user) };
163
195
  }
164
196
  catch (error) {
197
+ if (options.liveSession) {
198
+ const candidate = error;
199
+ const invalid = candidate?.status === 401 && candidate.code === 'OXY_LIVE_SESSION_INVALID_V1';
200
+ const denied = candidate?.status === 403 && candidate.code === 'OXY_LIVE_SESSION_POLICY_V1';
201
+ if (invalid)
202
+ this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
203
+ if (invalid || denied)
204
+ throw error;
205
+ throw new OxyApiError('Live session authority is unavailable', {
206
+ status: 503,
207
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
208
+ cause: error,
209
+ });
210
+ }
165
211
  // The session is gone: drop any user cached for it (#196).
166
212
  this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
167
213
  throw error;
@@ -131,7 +131,7 @@ export function createOxyMiddleware(host) {
131
131
  */
132
132
  function auth(options = {}) {
133
133
  const { debug = false, onError, onRefusal, loadUser = false, optional = false, serviceTokenJwksUrl = new URL('/.well-known/jwks.json', host.baseURL).toString(), expectedIssuer = OXY_JWT_ISSUER, expectedAudience = OXY_JWT_AUDIENCE, } = options;
134
- const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer);
134
+ const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer || options.liveSession);
135
135
  /**
136
136
  * Make a refusal observable to the HOST, without telling the client
137
137
  * anything it is not already told.
@@ -240,6 +240,17 @@ export function createOxyMiddleware(host) {
240
240
  // Handle service tokens (internal service-to-service auth)
241
241
  // Service tokens are stateless JWTs with type: 'service' — requires signature verification
242
242
  if (decoded.type === 'service') {
243
+ if (options.liveSession) {
244
+ const error = {
245
+ error: 'SESSION_REQUIRED',
246
+ message: 'A live user session is required',
247
+ code: 'SESSION_REQUIRED',
248
+ status: 403,
249
+ };
250
+ if (onError)
251
+ return onError(error);
252
+ return res.status(403).json(error);
253
+ }
243
254
  // Verify JWT signature, then audience / issuer / type / appId claims.
244
255
  //
245
256
  // Signature verification uses Node crypto rather than
@@ -652,6 +663,7 @@ export function createOxyMiddleware(host) {
652
663
  accessToken: token,
653
664
  supervisedAction: options.supervisedAction,
654
665
  fresh: options.freshSession,
666
+ ...(options.liveSession ? { liveSession: true } : {}),
655
667
  });
656
668
  if (!validationResult || !validationResult.valid || !validationResult.user) {
657
669
  recordRefusal(req, {
@@ -802,7 +814,12 @@ export function createOxyMiddleware(host) {
802
814
  return onError(refusal);
803
815
  return res.status(403).json(refusal);
804
816
  }
805
- const validationStatus = strictPresentedBearer && extractErrorStatus(validationError) !== 401 ? 503 : 401;
817
+ // A temporary authority failure is not a revoked session or an anonymous visitor.
818
+ const authorityUnavailable = extractErrorStatus(validationError) === 503;
819
+ const validationStatus = authorityUnavailable ||
820
+ (strictPresentedBearer && extractErrorStatus(validationError) !== 401)
821
+ ? 503
822
+ : 401;
806
823
  recordRefusal(req, {
807
824
  code: 'SESSION_VALIDATION_ERROR',
808
825
  stage: 'session',
@@ -818,7 +835,7 @@ export function createOxyMiddleware(host) {
818
835
  method: 'auth',
819
836
  }, validationError);
820
837
  }
821
- if (optional && !strictPresentedBearer) {
838
+ if (optional && !strictPresentedBearer && !authorityUnavailable) {
822
839
  req.userId = null;
823
840
  req.user = null;
824
841
  return next();
@@ -16,10 +16,11 @@
16
16
  * bearer) — never the raw token — for {@link SESSION_VALIDATION_CACHE_TTL_MS}
17
17
  * (30 s) by default, and never past the token's own `exp`.
18
18
  *
19
- * THE SECURITY CONTRACT: the cache never keeps a revoked session alive. Oxy
20
- * announces every session it ends on its service-event feed
21
- * (`./serviceEvents.ts`; `@oxy.so/contracts` `sessionRevocation.ts`), and the
22
- * cache is used ONLY while that feed is live:
19
+ * Oxy announces ended sessions on its service-event feed (`./serviceEvents.ts`;
20
+ * `@oxy.so/contracts` `sessionRevocation.ts`), and the cache is used ONLY while
21
+ * that feed is live. Feed health alone cannot prove that every event was
22
+ * delivered. Routes requiring a fresh session-row read can opt in to
23
+ * `liveSession`; see `ServerSessionValidateOptions` for its scope.
23
24
  *
24
25
  * - no feed (this process has no service token: no credential and no attested
25
26
  * workload, as in a test or a local checkout), a feed not yet connected, a
@@ -43,9 +44,10 @@
43
44
  * logout made through this client still drop entries too.
44
45
  */
45
46
  import { createHash } from 'node:crypto';
46
- import { safetySessionInputSchema, safetySessionValidationSchema, } from '@oxy.so/contracts';
47
+ import { accountActorChainSchema, safetySessionInputSchema, safetySessionValidationSchema, } from '@oxy.so/contracts';
47
48
  import { jwtDecode } from 'jwt-decode';
48
49
  import { SessionApi } from '../api/session.js';
50
+ import { OxyApiError } from '../OxyServices.errors.js';
49
51
  import { normalizeUserIdentity } from '../utils/userIdentity.js';
50
52
  /** Default lifetime of one cached validation. */
51
53
  export const SESSION_VALIDATION_CACHE_TTL_MS = 30000;
@@ -78,7 +80,29 @@ export class ServerSessionApi extends SessionApi {
78
80
  this.revoked = new Map();
79
81
  }
80
82
  async validate(sessionId, options = {}) {
81
- const { supervisedAction, fresh, ...rest } = options;
83
+ const { supervisedAction, fresh, liveSession, ...rest } = options;
84
+ if (liveSession) {
85
+ if (supervisedAction)
86
+ throw new OxyApiError('Live supervised session authority is unavailable', {
87
+ status: 503,
88
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
89
+ });
90
+ try {
91
+ const result = await this.validateRequest(sessionId, { ...rest, liveSession: true });
92
+ const actor = accountActorChainSchema.safeParse(result.actor);
93
+ if (!actor.success || actor.data.effectiveAccountId !== result.user.id)
94
+ throw new OxyApiError('Invalid live session authority response', {
95
+ status: 503,
96
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
97
+ });
98
+ return result;
99
+ }
100
+ catch (error) {
101
+ if (error?.status === 401)
102
+ this.refuse(sessionId);
103
+ throw error;
104
+ }
105
+ }
82
106
  if (supervisedAction)
83
107
  return this.validateSupervised(sessionId, supervisedAction, rest);
84
108
  if (!rest.accessToken || rest.deviceFingerprint || this.validationTtlMs <= 0) {
@@ -85,6 +85,10 @@ export declare class SessionApi {
85
85
  * validates the session it was issued for.
86
86
  */
87
87
  validate(sessionId: string, options?: SessionValidateOptions): Promise<SessionValidation>;
88
+ /** Server implementations use this transport after enforcing their own contract. */
89
+ protected validateRequest(sessionId: string, options: SessionValidateOptions & {
90
+ liveSession?: boolean;
91
+ }): Promise<SessionValidation>;
88
92
  /** The sessions that belong with `sessionId` (same account set). */
89
93
  list(sessionId: string): Promise<ClientSession[]>;
90
94
  /** Sign out `sessionId`, or — with `targetSessionId` — one of the sessions beside it. */
@@ -189,11 +189,13 @@ export interface AuthMiddlewareOptions {
189
189
  /** Keep anonymous access, but reject every supplied invalid credential or authority outage. */
190
190
  strictOptionalBearer?: boolean;
191
191
  /**
192
- * Ask Oxy about the session on every request instead of reusing a recent
193
- * successful validation (`OxyServerConfig.sessionValidationCacheTtlMs`, 30 s).
194
- * For routes where a just-revoked session must not act — deletion, payments.
192
+ * Ask Oxy instead of reusing this server's recent successful validation
193
+ * (`OxyServerConfig.sessionValidationCacheTtlMs`, 30 s). Oxy may itself
194
+ * serve a cached answer; use `liveSession` for a live session-row read.
195
195
  */
196
196
  freshSession?: boolean;
197
+ /** Require a live session-row read. Unsupported servers fail with 503. */
198
+ liveSession?: boolean;
197
199
  /**
198
200
  * Called whenever a PRESENTED credential is refused, on the optional path as
199
201
  * well as the blocking one. Runs before the response (if any) and after
@@ -237,6 +239,7 @@ export interface OxyMiddlewareHost {
237
239
  accessToken?: string;
238
240
  supervisedAction?: SupervisedAction;
239
241
  fresh?: boolean;
242
+ liveSession?: boolean;
240
243
  }): Promise<{
241
244
  valid: boolean;
242
245
  user?: User;
@@ -4,6 +4,17 @@ import { SessionApi, type SessionValidateOptions, type SessionValidation } from
4
4
  /** Default lifetime of one cached validation. */
5
5
  export declare const SESSION_VALIDATION_CACHE_TTL_MS = 30000;
6
6
  export interface ServerSessionValidateOptions extends SessionValidateOptions {
7
+ /**
8
+ * Require a session-row read on a PostgreSQL writer. Old servers and
9
+ * unavailable authority fail closed with 503. The read only reflects commits
10
+ * visible to its database snapshot; requests already authorized can finish.
11
+ * This does not make account, grant or supervision data fresh. An unconfirmed
12
+ * user, grant or policy negative returns 503 rather than invalidating a
13
+ * session. Expiry is checked against the consumer's clock; skew can cause
14
+ * unavailability. Each validation reaches Oxy and its activity bookkeeping;
15
+ * assess database and Redis load before enabling it on a route.
16
+ */
17
+ liveSession?: boolean;
7
18
  /**
8
19
  * A reviewed route category. Oxy then validates the bearer through this
9
20
  * service's live `safety:<action>:v1` authority, the only lane a supervised
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.10.4",
3
+ "version": "7.10.6",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -11,6 +11,7 @@ import type { OxyContext } from '../client/context';
11
11
  import type { ClientSession } from '../models/session';
12
12
  import type { User } from '../models/interfaces';
13
13
  import { normalizeUserIdentity } from '../utils/userIdentity';
14
+ import { OxyApiError } from '../OxyServices.errors';
14
15
 
15
16
  /**
16
17
  * Reads the device credential this client holds, if any — `{ deviceId,
@@ -188,18 +189,40 @@ export class SessionApi {
188
189
  async validate(
189
190
  sessionId: string,
190
191
  options: SessionValidateOptions = {},
192
+ ): Promise<SessionValidation> {
193
+ if ('liveSession' in options)
194
+ throw new OxyApiError('Live session validation requires OxyServer', {
195
+ status: 503,
196
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
197
+ });
198
+ return this.validateRequest(sessionId, options);
199
+ }
200
+
201
+ /** Server implementations use this transport after enforcing their own contract. */
202
+ protected async validateRequest(
203
+ sessionId: string,
204
+ options: SessionValidateOptions & { liveSession?: boolean },
191
205
  ): Promise<SessionValidation> {
192
206
  try {
193
- const validation = options.accessToken
194
- ? await this.ctx.request<SessionValidation>(
207
+ if (options.liveSession && !options.accessToken)
208
+ throw new OxyApiError('Live session authority requires a bearer', {
209
+ status: 503,
210
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
211
+ });
212
+ const validation: SessionValidation & { liveSessionAuthority?: unknown } = options.accessToken
213
+ ? await this.ctx.request<SessionValidation & { liveSessionAuthority?: unknown }>(
195
214
  'GET',
196
215
  `/session/validate-header/${encodeURIComponent(sessionId)}`,
197
216
  undefined,
198
217
  {
199
218
  cache: false,
200
219
  skipAuth: true,
220
+ ...(options.liveSession ? { deduplicate: false, retry: false } : {}),
201
221
  headers: {
202
222
  Authorization: `Bearer ${options.accessToken}`,
223
+ ...(options.liveSession
224
+ ? { 'x-oxy-session-authority': 'live-v1', 'cache-control': 'no-store' }
225
+ : {}),
203
226
  ...(options.deviceFingerprint
204
227
  ? { 'x-device-fingerprint': options.deviceFingerprint }
205
228
  : {}),
@@ -217,8 +240,36 @@ export class SessionApi {
217
240
  },
218
241
  { cache: false },
219
242
  );
243
+ if (
244
+ options.liveSession &&
245
+ (validation.liveSessionAuthority !== 'session-row-v1' ||
246
+ validation.valid !== true ||
247
+ validation.sessionId !== sessionId ||
248
+ typeof validation.expiresAt !== 'string' ||
249
+ !Number.isFinite(Date.parse(validation.expiresAt)) ||
250
+ Date.parse(validation.expiresAt) <= Date.now() ||
251
+ typeof validation.lastActivity !== 'string' ||
252
+ !Number.isFinite(Date.parse(validation.lastActivity)))
253
+ )
254
+ throw new OxyApiError('Live session authority is unavailable', {
255
+ status: 503,
256
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
257
+ });
220
258
  return { ...validation, user: normalizeUserIdentity(validation.user) };
221
259
  } catch (error) {
260
+ if (options.liveSession) {
261
+ const candidate = error as { status?: unknown; code?: unknown } | null;
262
+ const invalid =
263
+ candidate?.status === 401 && candidate.code === 'OXY_LIVE_SESSION_INVALID_V1';
264
+ const denied = candidate?.status === 403 && candidate.code === 'OXY_LIVE_SESSION_POLICY_V1';
265
+ if (invalid) this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
266
+ if (invalid || denied) throw error;
267
+ throw new OxyApiError('Live session authority is unavailable', {
268
+ status: 503,
269
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
270
+ cause: error,
271
+ });
272
+ }
222
273
  // The session is gone: drop any user cached for it (#196).
223
274
  this.ctx.oxy.cache.delete(`GET:/session/user/${sessionId}`);
224
275
  throw error;
@@ -278,11 +278,13 @@ export interface AuthMiddlewareOptions {
278
278
  /** Keep anonymous access, but reject every supplied invalid credential or authority outage. */
279
279
  strictOptionalBearer?: boolean;
280
280
  /**
281
- * Ask Oxy about the session on every request instead of reusing a recent
282
- * successful validation (`OxyServerConfig.sessionValidationCacheTtlMs`, 30 s).
283
- * For routes where a just-revoked session must not act — deletion, payments.
281
+ * Ask Oxy instead of reusing this server's recent successful validation
282
+ * (`OxyServerConfig.sessionValidationCacheTtlMs`, 30 s). Oxy may itself
283
+ * serve a cached answer; use `liveSession` for a live session-row read.
284
284
  */
285
285
  freshSession?: boolean;
286
+ /** Require a live session-row read. Unsupported servers fail with 503. */
287
+ liveSession?: boolean;
286
288
  /**
287
289
  * Called whenever a PRESENTED credential is refused, on the optional path as
288
290
  * well as the blocking one. Runs before the response (if any) and after
@@ -329,6 +331,7 @@ export interface OxyMiddlewareHost {
329
331
  accessToken?: string;
330
332
  supervisedAction?: SupervisedAction;
331
333
  fresh?: boolean;
334
+ liveSession?: boolean;
332
335
  },
333
336
  ): Promise<{ valid: boolean; user?: User; actor?: unknown } | null>;
334
337
  verifyActingAs(
@@ -443,7 +446,9 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
443
446
  expectedIssuer = OXY_JWT_ISSUER,
444
447
  expectedAudience = OXY_JWT_AUDIENCE,
445
448
  } = options;
446
- const strictPresentedBearer = Boolean(options.supervisedAction || options.strictOptionalBearer);
449
+ const strictPresentedBearer = Boolean(
450
+ options.supervisedAction || options.strictOptionalBearer || options.liveSession,
451
+ );
447
452
 
448
453
  /**
449
454
  * Make a refusal observable to the HOST, without telling the client
@@ -567,6 +572,16 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
567
572
  // Handle service tokens (internal service-to-service auth)
568
573
  // Service tokens are stateless JWTs with type: 'service' — requires signature verification
569
574
  if (decoded.type === 'service') {
575
+ if (options.liveSession) {
576
+ const error = {
577
+ error: 'SESSION_REQUIRED',
578
+ message: 'A live user session is required',
579
+ code: 'SESSION_REQUIRED',
580
+ status: 403,
581
+ };
582
+ if (onError) return onError(error);
583
+ return res.status(403).json(error);
584
+ }
570
585
  // Verify JWT signature, then audience / issuer / type / appId claims.
571
586
  //
572
587
  // Signature verification uses Node crypto rather than
@@ -1002,6 +1017,7 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
1002
1017
  accessToken: token,
1003
1018
  supervisedAction: options.supervisedAction,
1004
1019
  fresh: options.freshSession,
1020
+ ...(options.liveSession ? { liveSession: true } : {}),
1005
1021
  });
1006
1022
 
1007
1023
  if (!validationResult || !validationResult.valid || !validationResult.user) {
@@ -1158,8 +1174,13 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
1158
1174
  if (onError) return onError(refusal);
1159
1175
  return res.status(403).json(refusal);
1160
1176
  }
1177
+ // A temporary authority failure is not a revoked session or an anonymous visitor.
1178
+ const authorityUnavailable = extractErrorStatus(validationError) === 503;
1161
1179
  const validationStatus =
1162
- strictPresentedBearer && extractErrorStatus(validationError) !== 401 ? 503 : 401;
1180
+ authorityUnavailable ||
1181
+ (strictPresentedBearer && extractErrorStatus(validationError) !== 401)
1182
+ ? 503
1183
+ : 401;
1163
1184
  recordRefusal(req, {
1164
1185
  code: 'SESSION_VALIDATION_ERROR',
1165
1186
  stage: 'session',
@@ -1180,7 +1201,7 @@ export function createOxyMiddleware(host: OxyMiddlewareHost) {
1180
1201
  );
1181
1202
  }
1182
1203
 
1183
- if (optional && !strictPresentedBearer) {
1204
+ if (optional && !strictPresentedBearer && !authorityUnavailable) {
1184
1205
  req.userId = null;
1185
1206
  req.user = null;
1186
1207
  return next();
@@ -16,10 +16,11 @@
16
16
  * bearer) — never the raw token — for {@link SESSION_VALIDATION_CACHE_TTL_MS}
17
17
  * (30 s) by default, and never past the token's own `exp`.
18
18
  *
19
- * THE SECURITY CONTRACT: the cache never keeps a revoked session alive. Oxy
20
- * announces every session it ends on its service-event feed
21
- * (`./serviceEvents.ts`; `@oxy.so/contracts` `sessionRevocation.ts`), and the
22
- * cache is used ONLY while that feed is live:
19
+ * Oxy announces ended sessions on its service-event feed (`./serviceEvents.ts`;
20
+ * `@oxy.so/contracts` `sessionRevocation.ts`), and the cache is used ONLY while
21
+ * that feed is live. Feed health alone cannot prove that every event was
22
+ * delivered. Routes requiring a fresh session-row read can opt in to
23
+ * `liveSession`; see `ServerSessionValidateOptions` for its scope.
23
24
  *
24
25
  * - no feed (this process has no service token: no credential and no attested
25
26
  * workload, as in a test or a local checkout), a feed not yet connected, a
@@ -46,12 +47,14 @@ import { createHash } from 'node:crypto';
46
47
  import {
47
48
  type OxySessionRevocationEvent,
48
49
  type SupervisedAction,
50
+ accountActorChainSchema,
49
51
  safetySessionInputSchema,
50
52
  safetySessionValidationSchema,
51
53
  } from '@oxy.so/contracts';
52
54
  import { jwtDecode } from 'jwt-decode';
53
55
  import type { OxyContext } from '../client/context';
54
56
  import { SessionApi, type SessionValidateOptions, type SessionValidation } from '../api/session';
57
+ import { OxyApiError } from '../OxyServices.errors';
55
58
  import type { User } from '../models/interfaces';
56
59
  import { normalizeUserIdentity } from '../utils/userIdentity';
57
60
 
@@ -61,6 +64,17 @@ export const SESSION_VALIDATION_CACHE_TTL_MS = 30_000;
61
64
  const SESSION_VALIDATION_CACHE_MAX = 5_000;
62
65
 
63
66
  export interface ServerSessionValidateOptions extends SessionValidateOptions {
67
+ /**
68
+ * Require a session-row read on a PostgreSQL writer. Old servers and
69
+ * unavailable authority fail closed with 503. The read only reflects commits
70
+ * visible to its database snapshot; requests already authorized can finish.
71
+ * This does not make account, grant or supervision data fresh. An unconfirmed
72
+ * user, grant or policy negative returns 503 rather than invalidating a
73
+ * session. Expiry is checked against the consumer's clock; skew can cause
74
+ * unavailability. Each validation reaches Oxy and its activity bookkeeping;
75
+ * assess database and Redis load before enabling it on a route.
76
+ */
77
+ liveSession?: boolean;
64
78
  /**
65
79
  * A reviewed route category. Oxy then validates the bearer through this
66
80
  * service's live `safety:<action>:v1` authority, the only lane a supervised
@@ -121,7 +135,27 @@ export class ServerSessionApi extends SessionApi {
121
135
  sessionId: string,
122
136
  options: ServerSessionValidateOptions = {},
123
137
  ): Promise<SessionValidation> {
124
- const { supervisedAction, fresh, ...rest } = options;
138
+ const { supervisedAction, fresh, liveSession, ...rest } = options;
139
+ if (liveSession) {
140
+ if (supervisedAction)
141
+ throw new OxyApiError('Live supervised session authority is unavailable', {
142
+ status: 503,
143
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
144
+ });
145
+ try {
146
+ const result = await this.validateRequest(sessionId, { ...rest, liveSession: true });
147
+ const actor = accountActorChainSchema.safeParse(result.actor);
148
+ if (!actor.success || actor.data.effectiveAccountId !== result.user.id)
149
+ throw new OxyApiError('Invalid live session authority response', {
150
+ status: 503,
151
+ code: 'SESSION_AUTHORITY_UNAVAILABLE',
152
+ });
153
+ return result;
154
+ } catch (error) {
155
+ if ((error as { status?: unknown } | null)?.status === 401) this.refuse(sessionId);
156
+ throw error;
157
+ }
158
+ }
125
159
  if (supervisedAction) return this.validateSupervised(sessionId, supervisedAction, rest);
126
160
  if (!rest.accessToken || rest.deviceFingerprint || this.validationTtlMs <= 0) {
127
161
  return super.validate(sessionId, rest);