@oxy.so/core 7.10.5 → 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.
- package/dist/cjs/api/session.js +46 -0
- package/dist/cjs/server/middleware.js +13 -1
- package/dist/cjs/server/session.js +29 -5
- package/dist/esm/api/session.js +46 -0
- package/dist/esm/server/middleware.js +13 -1
- package/dist/esm/server/session.js +30 -6
- package/dist/types/api/session.d.ts +4 -0
- package/dist/types/server/middleware.d.ts +6 -3
- package/dist/types/server/session.d.ts +11 -0
- package/package.json +1 -1
- package/src/api/session.ts +53 -2
- package/src/server/middleware.ts +20 -4
- package/src/server/session.ts +39 -5
package/dist/cjs/api/session.js
CHANGED
|
@@ -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, {
|
|
@@ -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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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) {
|
package/dist/esm/api/session.js
CHANGED
|
@@ -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, {
|
|
@@ -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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
package/src/api/session.ts
CHANGED
|
@@ -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
|
-
|
|
194
|
-
|
|
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;
|
package/src/server/middleware.ts
CHANGED
|
@@ -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
|
|
282
|
-
*
|
|
283
|
-
*
|
|
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(
|
|
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) {
|
package/src/server/session.ts
CHANGED
|
@@ -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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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);
|