@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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Present-requester assertions (ADR 0025).
3
+ *
4
+ * A first-party product backend holding a signed-in person's live Oxy session
5
+ * trades it with Oxy (`OxyServices.mintRequesterAssertion`) for a one-use,
6
+ * short-lived `OXY-REQUESTER+JWT`, and sends that — never the person's bearer —
7
+ * to the audience service (Alia) beside its own service token. The audience
8
+ * mounts {@link createOxyRequesterAssertionAuth} after `createOxyAuthMiddleware`
9
+ * and gets `req.userId` from the verified, introspected and consumed assertion.
10
+ *
11
+ * Signing and stateless verification live here so Oxy (the signer) and every
12
+ * verifier share one implementation of the format.
13
+ */
14
+ import { createPublicKey, sign as signBytes, verify as verifyBytes, } from 'node:crypto';
15
+ import { z } from 'zod';
16
+ export const OXY_REQUESTER_ASSERTION_TYPE = 'OXY-REQUESTER+JWT';
17
+ /** The request header an audience reads the assertion from. */
18
+ export const OXY_REQUESTER_ASSERTION_HEADER = 'x-oxy-requester-assertion';
19
+ /** Oxy signs for 120 s; no verifier accepts a lifetime above this ceiling. */
20
+ export const OXY_REQUESTER_ASSERTION_MAX_TTL_SECONDS = 300;
21
+ export const OXY_REQUESTER_ASSERTION_DEFAULT_ISSUER = 'https://api.oxy.so';
22
+ const ALGORITHM = 'EdDSA';
23
+ const CLOCK_SKEW_SECONDS = 5;
24
+ const KEY_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
25
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
26
+ const MAX_ASSERTION_BYTES = 4096;
27
+ const identifier = z.string().min(1).max(128);
28
+ export const oxyRequesterAssertionClaimsSchema = z.object({
29
+ iss: z.string().min(1).max(256),
30
+ aud: identifier,
31
+ /** The requester account. */
32
+ sub: identifier,
33
+ jti: z.string().uuid(),
34
+ iat: z.number().int().nonnegative(),
35
+ exp: z.number().int().positive(),
36
+ /** The application the assertion was minted for, and the only valid presenter. */
37
+ azp: identifier,
38
+ /** The exact credential of that application. */
39
+ cid: identifier,
40
+ /** The one native agent this requester may reach with it. */
41
+ agentId: identifier,
42
+ }).strict();
43
+ export class OxyRequesterAssertionError extends Error {
44
+ constructor(code) {
45
+ super(`Requester assertion rejected: ${code}`);
46
+ this.code = code;
47
+ this.name = 'OxyRequesterAssertionError';
48
+ }
49
+ }
50
+ function base64Url(value) {
51
+ return Buffer.from(value).toString('base64url');
52
+ }
53
+ function decodeObject(segment) {
54
+ if (!BASE64URL_PATTERN.test(segment))
55
+ throw new OxyRequesterAssertionError('malformed');
56
+ const bytes = Buffer.from(segment, 'base64url');
57
+ if (bytes.toString('base64url') !== segment)
58
+ throw new OxyRequesterAssertionError('malformed');
59
+ let value;
60
+ try {
61
+ value = JSON.parse(bytes.toString('utf8'));
62
+ }
63
+ catch {
64
+ throw new OxyRequesterAssertionError('malformed');
65
+ }
66
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
67
+ throw new OxyRequesterAssertionError('malformed');
68
+ }
69
+ return value;
70
+ }
71
+ /** Signs an assertion. Oxy is the only intended caller. */
72
+ export function signOxyRequesterAssertion(claims, signing) {
73
+ const parsed = oxyRequesterAssertionClaimsSchema.parse(claims);
74
+ if (parsed.exp <= parsed.iat || parsed.exp - parsed.iat > OXY_REQUESTER_ASSERTION_MAX_TTL_SECONDS) {
75
+ throw new OxyRequesterAssertionError('ttl_exceeded');
76
+ }
77
+ if (!KEY_ID_PATTERN.test(signing.keyId))
78
+ throw new OxyRequesterAssertionError('malformed');
79
+ if (signing.privateKey.asymmetricKeyType !== 'ed25519') {
80
+ throw new OxyRequesterAssertionError('unknown_key');
81
+ }
82
+ const header = { alg: ALGORITHM, typ: OXY_REQUESTER_ASSERTION_TYPE, kid: signing.keyId };
83
+ const input = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(parsed))}`;
84
+ return `${input}.${base64Url(signBytes(null, Buffer.from(input), signing.privateKey))}`;
85
+ }
86
+ /** Reads the `kid` without trusting anything else in the token. */
87
+ export function readOxyRequesterAssertionKeyId(token) {
88
+ if (token.length > MAX_ASSERTION_BYTES)
89
+ throw new OxyRequesterAssertionError('malformed');
90
+ const segments = token.split('.');
91
+ if (segments.length !== 3 || segments.some((segment) => segment.length === 0)) {
92
+ throw new OxyRequesterAssertionError('malformed');
93
+ }
94
+ const header = decodeObject(segments[0]);
95
+ if (Object.keys(header).length !== 3
96
+ || header.alg !== ALGORITHM
97
+ || header.typ !== OXY_REQUESTER_ASSERTION_TYPE
98
+ || typeof header.kid !== 'string'
99
+ || !KEY_ID_PATTERN.test(header.kid)) {
100
+ throw new OxyRequesterAssertionError('malformed');
101
+ }
102
+ return header.kid;
103
+ }
104
+ /**
105
+ * Stateless verification: header, signature, claim shape, issuer, audience and
106
+ * lifetime. It does NOT prove the assertion is unused or that the requester's
107
+ * session is still live — only introspection does.
108
+ */
109
+ export function verifyOxyRequesterAssertion(token, options) {
110
+ readOxyRequesterAssertionKeyId(token);
111
+ const [encodedHeader, encodedPayload, encodedSignature] = token.split('.');
112
+ const publicKey = options.publicKey;
113
+ if (!publicKey || publicKey.asymmetricKeyType !== 'ed25519') {
114
+ throw new OxyRequesterAssertionError('unknown_key');
115
+ }
116
+ if (!BASE64URL_PATTERN.test(encodedSignature))
117
+ throw new OxyRequesterAssertionError('malformed');
118
+ const signature = Buffer.from(encodedSignature, 'base64url');
119
+ if (signature.length !== 64 || signature.toString('base64url') !== encodedSignature) {
120
+ throw new OxyRequesterAssertionError('malformed');
121
+ }
122
+ if (!verifyBytes(null, Buffer.from(`${encodedHeader}.${encodedPayload}`), publicKey, signature)) {
123
+ throw new OxyRequesterAssertionError('invalid_signature');
124
+ }
125
+ const parsed = oxyRequesterAssertionClaimsSchema.safeParse(decodeObject(encodedPayload));
126
+ if (!parsed.success)
127
+ throw new OxyRequesterAssertionError('invalid_claims');
128
+ const claims = parsed.data;
129
+ const now = Math.floor((options.now ?? new Date()).getTime() / 1000);
130
+ if (claims.iss !== options.issuer)
131
+ throw new OxyRequesterAssertionError('wrong_issuer');
132
+ if (claims.aud !== options.audience)
133
+ throw new OxyRequesterAssertionError('wrong_audience');
134
+ if (claims.iat > now + CLOCK_SKEW_SECONDS)
135
+ throw new OxyRequesterAssertionError('not_yet_valid');
136
+ if (claims.exp <= now)
137
+ throw new OxyRequesterAssertionError('expired');
138
+ if (claims.exp <= claims.iat || claims.exp - claims.iat > OXY_REQUESTER_ASSERTION_MAX_TTL_SECONDS) {
139
+ throw new OxyRequesterAssertionError('ttl_exceeded');
140
+ }
141
+ return claims;
142
+ }
143
+ // ---------------------------------------------------------------------------
144
+ // JWKS
145
+ // ---------------------------------------------------------------------------
146
+ const JWKS_CACHE_MS = 5 * 60 * 1000;
147
+ const JWKS_REFRESH_FLOOR_MS = 60 * 1000;
148
+ const JWKS_FETCH_TIMEOUT_MS = 5000;
149
+ const JWKS_MAX_BYTES = 64 * 1024;
150
+ const JWKS_MAX_KEYS = 20;
151
+ function parseJwks(value) {
152
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
153
+ throw new Error('JWKS is malformed');
154
+ const keys = value.keys;
155
+ if (!Array.isArray(keys) || keys.length === 0 || keys.length > JWKS_MAX_KEYS)
156
+ throw new Error('JWKS has an invalid key set');
157
+ const result = new Map();
158
+ for (const entry of keys) {
159
+ if (typeof entry !== 'object' || entry === null || Array.isArray(entry))
160
+ throw new Error('JWKS key is malformed');
161
+ const key = entry;
162
+ if (key.kty !== 'OKP'
163
+ || key.crv !== 'Ed25519'
164
+ || key.alg !== 'EdDSA'
165
+ || key.use !== 'sig'
166
+ || typeof key.x !== 'string'
167
+ || typeof key.kid !== 'string'
168
+ || !KEY_ID_PATTERN.test(key.kid)
169
+ || Object.prototype.hasOwnProperty.call(key, 'd')
170
+ || result.has(key.kid))
171
+ throw new Error('JWKS key is unsupported');
172
+ const x = Buffer.from(key.x, 'base64url');
173
+ if (x.length !== 32 || x.toString('base64url') !== key.x)
174
+ throw new Error('JWKS key is not Ed25519');
175
+ result.set(key.kid, createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: key.x }, format: 'jwk' }));
176
+ }
177
+ return result;
178
+ }
179
+ /**
180
+ * Cached Ed25519 JWKS resolver. An unknown `kid` refreshes at most once a
181
+ * minute, so a flood of forged key ids cannot turn into a flood of fetches. A
182
+ * failed refresh keeps serving keys that are still inside their cache window.
183
+ */
184
+ export function createOxyJwksKeyResolver(options) {
185
+ const fetchImpl = options.fetch ?? fetch;
186
+ const clock = options.clock ?? Date.now;
187
+ let keys = new Map();
188
+ let expiresAt = 0;
189
+ let lastAttemptAt = Number.NEGATIVE_INFINITY;
190
+ let pending;
191
+ const refresh = async () => {
192
+ if (pending)
193
+ return pending;
194
+ lastAttemptAt = clock();
195
+ pending = (async () => {
196
+ const controller = new AbortController();
197
+ const timer = setTimeout(() => controller.abort(), JWKS_FETCH_TIMEOUT_MS);
198
+ try {
199
+ const response = await fetchImpl(options.jwksUrl, {
200
+ method: 'GET',
201
+ headers: { accept: 'application/json' },
202
+ redirect: 'error',
203
+ signal: controller.signal,
204
+ });
205
+ if (!response.ok)
206
+ throw new Error(`JWKS returned HTTP ${response.status}`);
207
+ const body = await response.text();
208
+ if (Buffer.byteLength(body, 'utf8') > JWKS_MAX_BYTES)
209
+ throw new Error('JWKS exceeds the size limit');
210
+ keys = parseJwks(JSON.parse(body));
211
+ expiresAt = clock() + JWKS_CACHE_MS;
212
+ }
213
+ finally {
214
+ clearTimeout(timer);
215
+ }
216
+ })();
217
+ try {
218
+ await pending;
219
+ }
220
+ finally {
221
+ pending = undefined;
222
+ }
223
+ };
224
+ return async (keyId) => {
225
+ const now = clock();
226
+ const cached = keys.get(keyId);
227
+ if (cached && expiresAt > now)
228
+ return cached;
229
+ if (expiresAt <= now || now - lastAttemptAt >= JWKS_REFRESH_FLOOR_MS) {
230
+ try {
231
+ await refresh();
232
+ }
233
+ catch {
234
+ // Fall through: a still-valid cached key is served, anything else is unknown.
235
+ }
236
+ }
237
+ return expiresAt > clock() ? keys.get(keyId) : undefined;
238
+ };
239
+ }
240
+ function reject(req, res, options, status, error, code) {
241
+ options.onRejected?.({
242
+ code,
243
+ status,
244
+ applicationId: req.serviceApp?.appId ?? null,
245
+ });
246
+ res.status(status).json({ error, code, message: 'The requester assertion was not accepted', status });
247
+ }
248
+ function singleHeader(req, name) {
249
+ const value = req.headers[name];
250
+ if (value === undefined)
251
+ return null;
252
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ASSERTION_BYTES)
253
+ return 'invalid';
254
+ return value;
255
+ }
256
+ /**
257
+ * Accepts an `X-Oxy-Requester-Assertion` presented beside a VERIFIED service
258
+ * token. Mount it after `createOxyAuthMiddleware`. Without the header it does
259
+ * nothing, so it composes with every other lane.
260
+ *
261
+ * `introspector` must be an `OxyServices` configured with THIS service's own
262
+ * credential (`configureServiceAuth`): Oxy only lets the audience's application
263
+ * consume an assertion.
264
+ */
265
+ export function createOxyRequesterAssertionAuth(introspector, options) {
266
+ const issuer = options.issuer ?? OXY_REQUESTER_ASSERTION_DEFAULT_ISSUER;
267
+ let resolvePublicKey = options.resolvePublicKey;
268
+ const resolver = () => {
269
+ resolvePublicKey ?? (resolvePublicKey = createOxyJwksKeyResolver({
270
+ jwksUrl: options.jwksUrl
271
+ ?? new URL('/capabilities/.well-known/jwks.json', introspector.getBaseURL()).toString(),
272
+ }));
273
+ return resolvePublicKey;
274
+ };
275
+ return async (req, res, next) => {
276
+ const assertion = singleHeader(req, OXY_REQUESTER_ASSERTION_HEADER);
277
+ if (assertion === null) {
278
+ next();
279
+ return;
280
+ }
281
+ if (assertion === 'invalid') {
282
+ reject(req, res, options, 401, 'REQUESTER_ASSERTION_INVALID', 'malformed');
283
+ return;
284
+ }
285
+ const request = req;
286
+ const serviceApp = request.serviceApp;
287
+ if (!serviceApp) {
288
+ reject(req, res, options, 401, 'REQUESTER_ASSERTION_REQUIRES_SERVICE_TOKEN', 'service_token_required');
289
+ return;
290
+ }
291
+ // One identity channel per request. An offline delegation header or an
292
+ // identity some earlier middleware attached must never be combined with, or
293
+ // silently replaced by, a present-requester assertion.
294
+ if (req.headers['x-oxy-user-id'] !== undefined
295
+ || request.serviceActingAs !== undefined
296
+ || (request.userId !== undefined && request.userId !== null)) {
297
+ reject(req, res, options, 400, 'REQUESTER_ASSERTION_CONFLICT', 'identity_conflict');
298
+ return;
299
+ }
300
+ let claims;
301
+ try {
302
+ const keyId = readOxyRequesterAssertionKeyId(assertion);
303
+ claims = verifyOxyRequesterAssertion(assertion, {
304
+ publicKey: await resolver()(keyId),
305
+ issuer,
306
+ audience: options.audience,
307
+ ...(options.now ? { now: options.now() } : {}),
308
+ });
309
+ }
310
+ catch (error) {
311
+ const code = error instanceof OxyRequesterAssertionError ? error.code : 'invalid_claims';
312
+ reject(req, res, options, 401, 'REQUESTER_ASSERTION_INVALID', code);
313
+ return;
314
+ }
315
+ // Bound to its presenter: a copy in anyone else's hands proves nothing,
316
+ // and checking before introspection means it cannot even be spent.
317
+ if (claims.azp !== serviceApp.appId || claims.cid !== serviceApp.credentialId) {
318
+ reject(req, res, options, 401, 'REQUESTER_ASSERTION_INVALID', 'presenter_mismatch');
319
+ return;
320
+ }
321
+ let introspection;
322
+ try {
323
+ introspection = await introspector.introspectRequesterAssertion({
324
+ assertion,
325
+ presenter: { applicationId: serviceApp.appId, credentialId: serviceApp.credentialId },
326
+ });
327
+ }
328
+ catch {
329
+ reject(req, res, options, 503, 'REQUESTER_ASSERTION_UNAVAILABLE', 'introspection_unavailable');
330
+ return;
331
+ }
332
+ if (introspection.active !== true
333
+ || introspection.requesterAccountId !== claims.sub
334
+ || introspection.agentId !== claims.agentId
335
+ || introspection.applicationId !== claims.azp
336
+ || introspection.credentialId !== claims.cid
337
+ || introspection.jti !== claims.jti) {
338
+ reject(req, res, options, 401, 'REQUESTER_ASSERTION_INACTIVE', 'inactive');
339
+ return;
340
+ }
341
+ request.userId = claims.sub;
342
+ request.user = { id: claims.sub };
343
+ request.oxyRequester = {
344
+ userId: claims.sub,
345
+ agentId: claims.agentId,
346
+ applicationId: claims.azp,
347
+ credentialId: claims.cid,
348
+ jti: claims.jti,
349
+ expiresAt: new Date(claims.exp * 1000).toISOString(),
350
+ };
351
+ next();
352
+ };
353
+ }