@oxy.so/core 1.4.0 → 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,396 @@
1
+ /**
2
+ * A refused credential is observable to the HOST — and to nobody else.
3
+ *
4
+ * The incident these lock in: Oxy served `{"keys":[]}` from
5
+ * `/.well-known/jwks.json` with no Ed25519 signing key bound. Every service
6
+ * token failed verification, the optional mount swallowed the failure into
7
+ * `next()`, the host answered its own generic 401, and the one fact that named
8
+ * the fault — an empty key set — was written down nowhere. These tests assert
9
+ * that each refusal class now lands on `req.oxyAuthRefusal`, reaches
10
+ * `onRefusal`, and emits exactly one `warn` carrying a stable code, while the
11
+ * bodies the client sees stay byte-identical and optional auth still refuses to
12
+ * 401 on its own.
13
+ */
14
+
15
+ import { generateKeyPairSync, sign as signBytes, type KeyObject } from 'node:crypto';
16
+ import { OxyServices } from '../../OxyServices';
17
+ import type { OxyAuthRefusal } from '../OxyServices.utility';
18
+ import { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyAuthRefusal } from '../../server/auth';
19
+ import { configureLogger, resetLoggerConfig, type LogEntry } from '../../logger';
20
+
21
+ const b64url = (value: string | Uint8Array): string => Buffer.from(value).toString('base64url');
22
+
23
+ const primary = generateKeyPairSync('ed25519');
24
+ const impostor = generateKeyPairSync('ed25519');
25
+ const primaryJwk = {
26
+ ...primary.publicKey.export({ format: 'jwk' }),
27
+ use: 'sig',
28
+ alg: 'EdDSA',
29
+ kid: 'service-2026-09-a',
30
+ };
31
+
32
+ function serviceToken(
33
+ privateKey: KeyObject,
34
+ keyId: string,
35
+ claims: Record<string, unknown> = {},
36
+ ): string {
37
+ const now = Math.floor(Date.now() / 1_000);
38
+ const header = { alg: 'EdDSA', typ: 'JWT', kid: keyId };
39
+ const payload = {
40
+ type: 'service',
41
+ appId: 'app-exact',
42
+ appName: 'Homiio',
43
+ credentialId: 'credential-exact',
44
+ ownerAccountId: 'account-exact',
45
+ environment: 'production',
46
+ scopes: ['inference:invoke'],
47
+ iss: 'oxy-auth',
48
+ aud: 'oxy-api',
49
+ iat: now,
50
+ exp: now + 3_600,
51
+ ...claims,
52
+ };
53
+ const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
54
+ return `${signingInput}.${b64url(signBytes(null, Buffer.from(signingInput), privateKey))}`;
55
+ }
56
+
57
+ /** An unsigned user token — the middleware decodes these, it never verifies them. */
58
+ function userToken(claims: Record<string, unknown>): string {
59
+ const now = Math.floor(Date.now() / 1_000);
60
+ const header = { alg: 'none', typ: 'JWT' };
61
+ const payload = { userId: 'user-1', exp: now + 3_600, ...claims };
62
+ return `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}.`;
63
+ }
64
+
65
+ function responseHarness() {
66
+ return {
67
+ statusCode: 0,
68
+ body: undefined as unknown,
69
+ status(code: number) {
70
+ this.statusCode = code;
71
+ return this;
72
+ },
73
+ json(body: unknown) {
74
+ this.body = body;
75
+ return this;
76
+ },
77
+ };
78
+ }
79
+
80
+ function requestHarness(headers: Record<string, string>) {
81
+ return { method: 'POST', path: '/v1/chat/completions', query: {}, headers };
82
+ }
83
+
84
+ function jwksResponse(body: unknown) {
85
+ return new Response(JSON.stringify(body), { status: 200 });
86
+ }
87
+
88
+ /** Drive a composed (optional-inside) middleware to completion. */
89
+ async function runMiddleware(
90
+ middleware: (req: unknown, res: unknown, next: unknown) => unknown,
91
+ headers: Record<string, string>,
92
+ ) {
93
+ const request = requestHarness(headers);
94
+ const response = responseHarness();
95
+ let settle!: () => void;
96
+ const settled = new Promise<void>((resolve) => { settle = resolve; });
97
+ const originalJson = response.json.bind(response);
98
+ response.json = (body: unknown) => {
99
+ const result = originalJson(body);
100
+ settle();
101
+ return result;
102
+ };
103
+ const next = jest.fn(() => settle());
104
+ await middleware(request as never, response as never, next as never);
105
+ await settled;
106
+ return { request, response, next };
107
+ }
108
+
109
+ let warnings: LogEntry[];
110
+
111
+ beforeEach(() => {
112
+ warnings = [];
113
+ configureLogger({
114
+ level: 'warn',
115
+ sink: (entry) => {
116
+ if (entry.level === 'warn') warnings.push(entry);
117
+ },
118
+ });
119
+ });
120
+
121
+ afterEach(() => {
122
+ resetLoggerConfig();
123
+ jest.restoreAllMocks();
124
+ });
125
+
126
+ function refusalWarnings(): LogEntry[] {
127
+ return warnings.filter((entry) => entry.message.startsWith('[oxy.auth] refused '));
128
+ }
129
+
130
+ describe('optional auth records why a presented credential was refused', () => {
131
+ /**
132
+ * Each case is a distinct production outage that answers the client the same
133
+ * way. `reason` is what tells them apart, so each case asserts on it.
134
+ */
135
+ const cases: Array<{
136
+ name: string;
137
+ jwks: unknown | 'unreachable';
138
+ token: string;
139
+ expected: { code: string; stage: OxyAuthRefusal['stage']; reason: RegExp; status: number };
140
+ }> = [
141
+ {
142
+ name: 'a signature that does not verify',
143
+ jwks: { keys: [primaryJwk] },
144
+ token: serviceToken(impostor.privateKey, primaryJwk.kid),
145
+ expected: {
146
+ code: 'INVALID_SERVICE_TOKEN',
147
+ stage: 'service-token',
148
+ reason: /signature is invalid/i,
149
+ status: 401,
150
+ },
151
+ },
152
+ {
153
+ name: 'a kid the key set does not carry',
154
+ jwks: { keys: [primaryJwk] },
155
+ token: serviceToken(impostor.privateKey, 'rotated-away'),
156
+ expected: {
157
+ code: 'INVALID_SERVICE_TOKEN',
158
+ stage: 'service-token',
159
+ reason: /signing key is unknown/i,
160
+ status: 401,
161
+ },
162
+ },
163
+ {
164
+ name: 'the empty key set Oxy actually served',
165
+ jwks: { keys: [] },
166
+ token: serviceToken(primary.privateKey, primaryJwk.kid),
167
+ expected: {
168
+ code: 'INVALID_SERVICE_TOKEN',
169
+ stage: 'service-token',
170
+ reason: /key set is unavailable/i,
171
+ status: 401,
172
+ },
173
+ },
174
+ {
175
+ name: 'a JWKS endpoint that cannot be reached',
176
+ jwks: 'unreachable',
177
+ token: serviceToken(primary.privateKey, primaryJwk.kid),
178
+ expected: {
179
+ code: 'INVALID_SERVICE_TOKEN',
180
+ stage: 'service-token',
181
+ reason: /key set is unavailable/i,
182
+ status: 401,
183
+ },
184
+ },
185
+ {
186
+ name: 'an expired service token',
187
+ jwks: { keys: [primaryJwk] },
188
+ token: serviceToken(primary.privateKey, primaryJwk.kid, { exp: 1 }),
189
+ expected: {
190
+ code: 'TOKEN_EXPIRED',
191
+ stage: 'service-token',
192
+ reason: /expired/i,
193
+ status: 401,
194
+ },
195
+ },
196
+ {
197
+ name: 'an audience this verifier does not answer for',
198
+ jwks: { keys: [primaryJwk] },
199
+ token: serviceToken(primary.privateKey, primaryJwk.kid, { aud: 'someone-else' }),
200
+ expected: {
201
+ code: 'INVALID_SERVICE_TOKEN_CLAIMS',
202
+ stage: 'service-token',
203
+ reason: /audience/i,
204
+ status: 401,
205
+ },
206
+ },
207
+ {
208
+ name: 'an issuer this verifier does not trust',
209
+ jwks: { keys: [primaryJwk] },
210
+ token: serviceToken(primary.privateKey, primaryJwk.kid, { iss: 'attacker' }),
211
+ expected: {
212
+ code: 'INVALID_SERVICE_TOKEN_CLAIMS',
213
+ stage: 'service-token',
214
+ reason: /issuer/i,
215
+ status: 401,
216
+ },
217
+ },
218
+ {
219
+ name: 'a service token missing a required claim',
220
+ jwks: { keys: [primaryJwk] },
221
+ token: serviceToken(primary.privateKey, primaryJwk.kid, { appName: undefined }),
222
+ expected: {
223
+ code: 'INVALID_SERVICE_TOKEN',
224
+ stage: 'service-token',
225
+ reason: /appName/,
226
+ status: 401,
227
+ },
228
+ },
229
+ ];
230
+
231
+ it.each(cases)('$name', async ({ jwks, token, expected }) => {
232
+ if (jwks === 'unreachable') {
233
+ jest.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('connect ECONNREFUSED'));
234
+ } else {
235
+ jest.spyOn(globalThis, 'fetch').mockResolvedValue(jwksResponse(jwks));
236
+ }
237
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
238
+ const refused: OxyAuthRefusal[] = [];
239
+
240
+ const { request, response, next } = await runMiddleware(
241
+ createOptionalOxyAuth(oxy, { auth: { onRefusal: (refusal) => refused.push(refusal) } }),
242
+ { authorization: `Bearer ${token}` },
243
+ );
244
+
245
+ // Optional auth still does not answer for itself.
246
+ expect(next).toHaveBeenCalledTimes(1);
247
+ expect(response.statusCode).toBe(0);
248
+ expect(response.body).toBeUndefined();
249
+
250
+ const recorded = getOxyAuthRefusal(request as never);
251
+ expect(recorded).toMatchObject({
252
+ code: expected.code,
253
+ stage: expected.stage,
254
+ status: expected.status,
255
+ optional: true,
256
+ });
257
+ expect(recorded?.reason).toMatch(expected.reason);
258
+ expect(refused).toEqual([recorded]);
259
+
260
+ const warned = refusalWarnings();
261
+ expect(warned).toHaveLength(1);
262
+ expect(warned[0]?.context).toMatchObject({ code: expected.code, optional: true });
263
+ // The credential itself never reaches a log line.
264
+ expect(JSON.stringify(warned[0])).not.toContain(token);
265
+ });
266
+
267
+ it('records a user token that carries no session', async () => {
268
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
269
+ const { request, next } = await runMiddleware(
270
+ createOptionalOxyAuth(oxy),
271
+ { authorization: `Bearer ${userToken({})}` },
272
+ );
273
+
274
+ expect(next).toHaveBeenCalledTimes(1);
275
+ expect(getOxyAuthRefusal(request as never)).toMatchObject({
276
+ code: 'SESSION_REQUIRED',
277
+ stage: 'token',
278
+ optional: true,
279
+ });
280
+ });
281
+
282
+ it('records a session the API refuses to validate, without logging the session id', async () => {
283
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
284
+ jest.spyOn(oxy, 'validateSession').mockResolvedValue({ valid: false });
285
+ const { request } = await runMiddleware(
286
+ createOptionalOxyAuth(oxy),
287
+ { authorization: `Bearer ${userToken({ sessionId: 'session-secret-value' })}` },
288
+ );
289
+
290
+ expect(getOxyAuthRefusal(request as never)).toMatchObject({
291
+ code: 'INVALID_SESSION',
292
+ stage: 'session',
293
+ });
294
+ expect(JSON.stringify(warnings)).not.toContain('session-secret-value');
295
+ });
296
+
297
+ it('keeps a validation transport failure diagnostic but id-free', async () => {
298
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
299
+ const transport = Object.assign(new Error('connect ECONNREFUSED https://api.oxy.test/session/validate/session-secret-value'), {
300
+ code: 'ECONNREFUSED',
301
+ });
302
+ jest.spyOn(oxy, 'validateSession').mockRejectedValue(transport);
303
+
304
+ const { request } = await runMiddleware(
305
+ createOptionalOxyAuth(oxy),
306
+ { authorization: `Bearer ${userToken({ sessionId: 'session-secret-value' })}` },
307
+ );
308
+
309
+ const recorded = getOxyAuthRefusal(request as never);
310
+ expect(recorded).toMatchObject({ code: 'SESSION_VALIDATION_ERROR', stage: 'session' });
311
+ expect(recorded?.reason).toContain('ECONNREFUSED');
312
+ expect(recorded?.reason).not.toContain('session-secret-value');
313
+ });
314
+
315
+ it('records a bearer that is not a JWT at all', async () => {
316
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
317
+ const { request } = await runMiddleware(createOptionalOxyAuth(oxy), {
318
+ authorization: 'Bearer not-a-jwt',
319
+ });
320
+
321
+ expect(getOxyAuthRefusal(request as never)).toMatchObject({
322
+ code: 'INVALID_TOKEN_FORMAT',
323
+ stage: 'token',
324
+ });
325
+ });
326
+
327
+ it('treats an absent credential as absence, not refusal', async () => {
328
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
329
+ const refused: OxyAuthRefusal[] = [];
330
+ const { request, response, next } = await runMiddleware(
331
+ createOptionalOxyAuth(oxy, { auth: { onRefusal: (refusal) => refused.push(refusal) } }),
332
+ {},
333
+ );
334
+
335
+ expect(next).toHaveBeenCalledTimes(1);
336
+ expect(response.statusCode).toBe(0);
337
+ expect(getOxyAuthRefusal(request as never)).toBeNull();
338
+ expect(refused).toEqual([]);
339
+ expect(refusalWarnings()).toEqual([]);
340
+ });
341
+
342
+ it('survives an onRefusal observer that throws', async () => {
343
+ jest.spyOn(globalThis, 'fetch').mockResolvedValue(jwksResponse({ keys: [] }));
344
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
345
+ const { request, response, next } = await runMiddleware(
346
+ createOptionalOxyAuth(oxy, {
347
+ auth: { onRefusal: () => { throw new Error('observer blew up'); } },
348
+ }),
349
+ { authorization: `Bearer ${serviceToken(primary.privateKey, primaryJwk.kid)}` },
350
+ );
351
+
352
+ expect(next).toHaveBeenCalledTimes(1);
353
+ expect(response.statusCode).toBe(0);
354
+ expect(getOxyAuthRefusal(request as never)).toMatchObject({ code: 'INVALID_SERVICE_TOKEN' });
355
+ });
356
+ });
357
+
358
+ describe('a blocking mount answers exactly what it always answered', () => {
359
+ it('keeps the generic 401 body while naming the refusal in the log', async () => {
360
+ jest.spyOn(globalThis, 'fetch').mockResolvedValue(jwksResponse({ keys: [] }));
361
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
362
+ const { request, response, next } = await runMiddleware(
363
+ createOxyAuthMiddleware(oxy),
364
+ { authorization: `Bearer ${serviceToken(primary.privateKey, primaryJwk.kid)}` },
365
+ );
366
+
367
+ expect(next).not.toHaveBeenCalled();
368
+ expect(response.statusCode).toBe(401);
369
+ // Unchanged, and deliberately uninformative.
370
+ expect(response.body).toEqual({ error: 'Unauthorized', message: 'Authentication required' });
371
+ expect(getOxyAuthRefusal(request as never)).toMatchObject({ code: 'INVALID_SERVICE_TOKEN' });
372
+ expect(
373
+ warnings.some((entry) => entry.message.includes('401 after refusal INVALID_SERVICE_TOKEN')),
374
+ ).toBe(true);
375
+ });
376
+
377
+ it('reports a non-optional refusal as non-optional', async () => {
378
+ jest.spyOn(globalThis, 'fetch').mockResolvedValue(jwksResponse({ keys: [] }));
379
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.test' });
380
+ const request = requestHarness({
381
+ authorization: `Bearer ${serviceToken(primary.privateKey, primaryJwk.kid)}`,
382
+ });
383
+ const response = responseHarness();
384
+
385
+ await oxy.auth()(request as never, response as never, jest.fn() as never);
386
+
387
+ expect(response.statusCode).toBe(401);
388
+ expect(getOxyAuthRefusal(request as never)).toMatchObject({
389
+ code: 'INVALID_SERVICE_TOKEN',
390
+ optional: false,
391
+ });
392
+ // The client is told the code on a blocking mount — that has always been
393
+ // true and is not what this change touches.
394
+ expect(response.body).toMatchObject({ code: 'INVALID_SERVICE_TOKEN', status: 401 });
395
+ });
396
+ });
@@ -1,9 +1,11 @@
1
1
  import type { NextFunction, Request, RequestHandler, Response } from 'express';
2
2
  import type { OxyServices } from '../OxyServices';
3
+ import type { OxyAuthRefusal } from '../mixins/OxyServices.utility';
4
+ import { logger } from '../logger';
3
5
  import { OXY_SERVICE_ENVIRONMENTS, type OxyServiceEnvironment } from '../utils/oxyServiceEnvironment';
4
6
 
5
7
  export { OXY_SERVICE_ENVIRONMENTS };
6
- export type { OxyServiceEnvironment };
8
+ export type { OxyServiceEnvironment, OxyAuthRefusal };
7
9
 
8
10
  export interface OxyRequestUser {
9
11
  id: string;
@@ -40,6 +42,12 @@ export interface OxyAuthRequest extends Request {
40
42
  sessionId?: string | null;
41
43
  serviceApp?: OxyServiceAppContext;
42
44
  serviceActingAs?: OxyServiceActingAsContext;
45
+ /**
46
+ * Why the presented credential was refused, when one was and the request
47
+ * still reached the handler (the optional path). Read it with
48
+ * {@link getOxyAuthRefusal} — never put it in a response body.
49
+ */
50
+ oxyAuthRefusal?: OxyAuthRefusal;
43
51
  }
44
52
 
45
53
  export interface OxyAuthenticatedRequest extends OxyAuthRequest {
@@ -210,9 +218,37 @@ export function getRequiredOxyUserId(req: Request): string {
210
218
  return userId;
211
219
  }
212
220
 
221
+ /**
222
+ * Why the credential on this request was refused, or `null` when none was
223
+ * presented (or it was accepted).
224
+ *
225
+ * Set by `oxy.auth()` — including the `optional: true` mount the composed
226
+ * helpers use, where a refusal otherwise leaves no trace: the request simply
227
+ * arrives unauthenticated and the host answers its own generic 401. Hosts log
228
+ * this beside their own 401; it is diagnostic and **must not be put into a
229
+ * response body**, which is exactly what it is kept out of.
230
+ */
231
+ export function getOxyAuthRefusal(req: Request): OxyAuthRefusal | null {
232
+ return (req as OxyAuthRequest).oxyAuthRefusal ?? null;
233
+ }
234
+
213
235
  export function requireOxyAuth(req: Request, res: Response, next: NextFunction): void {
214
236
  const userId = getOxyUserId(req);
215
237
  if (!userId) {
238
+ // The body stays exactly as generic as it was. The LOG does not: this 401
239
+ // is the one a caller sees when the optional resolver already refused a
240
+ // real credential, and printing that code here is what turns "Homiio got
241
+ // 401, Alia logged nothing" into one greppable line.
242
+ const refusal = getOxyAuthRefusal(req);
243
+ if (refusal) {
244
+ logger.warn(`[oxy.auth] 401 after refusal ${refusal.code}: ${refusal.reason}`, {
245
+ component: 'auth',
246
+ method: 'requireOxyAuth',
247
+ code: refusal.code,
248
+ stage: refusal.stage,
249
+ reason: refusal.reason,
250
+ });
251
+ }
216
252
  res.status(401).json({
217
253
  error: 'Unauthorized',
218
254
  message: 'Authentication required',
@@ -18,6 +18,7 @@
18
18
  export {
19
19
  createOptionalOxyAuth,
20
20
  createOxyAuthMiddleware,
21
+ getOxyAuthRefusal,
21
22
  getOxyBillingPrincipal,
22
23
  getOxyDelegatedUserId,
23
24
  getOxyRequestAttribution,
@@ -30,6 +31,7 @@ export {
30
31
  } from './auth';
31
32
  export type {
32
33
  OxyAuthenticatedRequest,
34
+ OxyAuthRefusal,
33
35
  OxyAuthMiddlewareOptions,
34
36
  OxyAuthRequest,
35
37
  OxyBillingPrincipal,