Skip to content

Commit f43cbbe

Browse files
author
David Ruzicka
committed
fix(phase-03): address post-merge recheck findings
- ClientAuthGate constructor: remove duplicate mode_from_env resolution block; precondition is now that config.mode is already resolved by resolveClientAuthGateConfig() before construction (single source of truth). Unknown mode value still guarded with fast-fail ClientAuthGateError. - http-transport getProfileState(): wrap gate construction in try/catch so a misconfigured gate logs at error level with profileId and rethrows. Outer handlePost catch now recognizes ClientAuthGateError and returns descriptive 500 JSON ("Gateway Configuration Error") instead of generic internal error. - http-transport warn log: add errorStack field for non-ClientAuthGateError gate exceptions so unexpected store bugs are not silently swallowed. - Tests (Issue 3B full audit): - Add: mode=optional + valid key → principal returned (unit + integration) - Add: gate-initialized log asserts mode, hasApiKeys, profileId fields - Add: message content assertions on ClientAuthGateError throws - Add: res.body checks on all 401 scenarios - Add: warn log profileId + error field assertions (scenarios 4, 6, 8, 9) - Add: valid key works when mode omitted (defaults-to-required happy path) - Remove: three constructor-level mode_from_env tests (behavior lives in resolveClientAuthGateConfig, tested in client-auth-gate-validator.test.ts); replaced with single test confirming mode_from_env is ignored when config.mode is already a resolved literal
1 parent c1d9e02 commit f43cbbe

4 files changed

Lines changed: 160 additions & 77 deletions

File tree

src/auth/client-auth-gate.test.ts

Lines changed: 32 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,27 @@ describe('ClientAuthGate (API key path only)', () => {
7171
);
7272

7373
await expect(gate.validate('wrong-key')).rejects.toBeInstanceOf(ClientAuthGateError);
74+
await expect(gate.validate('wrong-key')).rejects.toThrow(/no valid identity resolved/);
75+
});
76+
77+
it('returns principal when mode=optional and valid key is presented', async () => {
78+
const gate = new ClientAuthGate(
79+
'profile-a',
80+
{
81+
mode: 'optional',
82+
api_keys: {
83+
type: 'inline',
84+
keys: [{ key_from_env: ENV_VAR, subject: 'service-a', scopes: ['read'] }],
85+
},
86+
},
87+
makeLogger(),
88+
);
89+
90+
const principal = await gate.validate(VALID_KEY);
91+
expect(principal).not.toBeNull();
92+
expect(principal!.authType).toBe('token');
93+
expect(principal!.subject).toBe('service-a');
94+
expect(principal!.scopes).toEqual(['read']);
7495
});
7596

7697
it('returns null when API key is invalid and mode=optional', async () => {
@@ -121,6 +142,7 @@ describe('ClientAuthGate (API key path only)', () => {
121142
);
122143

123144
await expect(gate.validate(undefined)).rejects.toBeInstanceOf(ClientAuthGateError);
145+
await expect(gate.validate(undefined)).rejects.toThrow(/no token presented/);
124146
});
125147

126148
it('defaults mode to "required" when omitted (closed by default)', async () => {
@@ -137,6 +159,10 @@ describe('ClientAuthGate (API key path only)', () => {
137159

138160
await expect(gate.validate(undefined)).rejects.toBeInstanceOf(ClientAuthGateError);
139161
await expect(gate.validate('wrong-key')).rejects.toBeInstanceOf(ClientAuthGateError);
162+
// Happy path: valid key still resolves a principal even when mode was omitted.
163+
const principal = await gate.validate(VALID_KEY);
164+
expect(principal).not.toBeNull();
165+
expect(principal!.authType).toBe('token');
140166
});
141167

142168
it('returns null when mode=optional and no api_keys store is configured', async () => {
@@ -150,14 +176,17 @@ describe('ClientAuthGate (API key path only)', () => {
150176
expect(await gate.validate('any-token')).toBeNull();
151177
});
152178

153-
it('mode takes precedence over mode_from_env when both are set', async () => {
179+
it('mode_from_env field is ignored when mode is already set (constructor receives pre-resolved config)', async () => {
180+
// Constructor precondition: config.mode is always a resolved literal by the time
181+
// ClientAuthGate is constructed (resolveClientAuthGateConfig runs first in the
182+
// transport). mode_from_env resolution is tested in client-auth-gate-validator.test.ts.
154183
const modeEnv = 'CLIENT_AUTH_GATE_TEST_MODE';
155-
process.env[modeEnv] = 'optional'; // would make gate optional if read
184+
process.env[modeEnv] = 'optional'; // would flip mode if the constructor read it
156185
try {
157186
const gate = new ClientAuthGate(
158187
'profile-a',
159188
{
160-
mode: 'required', // explicit mode winsmode_from_env must be ignored
189+
mode: 'required', // explicit resolved mode wins; mode_from_env is ignored
161190
mode_from_env: modeEnv,
162191
api_keys: {
163192
type: 'inline',
@@ -172,55 +201,6 @@ describe('ClientAuthGate (API key path only)', () => {
172201
}
173202
});
174203

175-
it('resolves mode from mode_from_env when env var is set to optional', async () => {
176-
const modeEnv = 'CLIENT_AUTH_GATE_TEST_MODE';
177-
process.env[modeEnv] = 'optional';
178-
try {
179-
const gate = new ClientAuthGate(
180-
'profile-a',
181-
{
182-
mode_from_env: modeEnv,
183-
api_keys: {
184-
type: 'inline',
185-
keys: [{ key_from_env: ENV_VAR, subject: 'service-a' }],
186-
},
187-
},
188-
makeLogger(),
189-
);
190-
expect(await gate.validate('wrong-key')).toBeNull();
191-
} finally {
192-
delete process.env[modeEnv];
193-
}
194-
});
195-
196-
it('throws ClientAuthGateError in constructor when mode_from_env env var is not set', () => {
197-
expect(
198-
() =>
199-
new ClientAuthGate(
200-
'profile-a',
201-
{ mode_from_env: 'CLIENT_AUTH_GATE_TEST_MODE_UNSET' },
202-
makeLogger(),
203-
),
204-
).toThrow(ClientAuthGateError);
205-
});
206-
207-
it('throws ClientAuthGateError in constructor when mode_from_env env var has invalid value', () => {
208-
const modeEnv = 'CLIENT_AUTH_GATE_TEST_MODE';
209-
process.env[modeEnv] = 'superuser';
210-
try {
211-
expect(
212-
() =>
213-
new ClientAuthGate(
214-
'profile-a',
215-
{ mode_from_env: modeEnv },
216-
makeLogger(),
217-
),
218-
).toThrow(ClientAuthGateError);
219-
} finally {
220-
delete process.env[modeEnv];
221-
}
222-
});
223-
224204
it('throws ClientAuthGateError when token is provided, no api_keys store, and mode=required', async () => {
225205
// Pins the fall-through path: token present, apiKeyStore absent, mode=required
226206
// → must throw rather than silently returning null.

src/auth/client-auth-gate.ts

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -39,22 +39,18 @@ export class ClientAuthGate {
3939

4040
constructor(profileId: string, config: ClientAuthGateConfig, logger: Logger) {
4141
this.logger = logger;
42-
let resolvedMode = config.mode;
43-
if (!resolvedMode && config.mode_from_env) {
44-
const envValue = process.env[config.mode_from_env];
45-
if (!envValue) {
46-
throw new ClientAuthGateError(
47-
`client_auth_gate.mode_from_env: env var '${config.mode_from_env}' is not set`,
48-
);
49-
}
50-
if (envValue !== 'required' && envValue !== 'optional') {
51-
throw new ClientAuthGateError(
52-
`client_auth_gate.mode must be 'required' or 'optional', got '${envValue}'`,
53-
);
54-
}
55-
resolvedMode = envValue;
42+
// Precondition: config.mode must already be a resolved literal ('required' |
43+
// 'optional'). mode_from_env resolution belongs in resolveClientAuthGateConfig(),
44+
// which is always called before constructing ClientAuthGate in the transport.
45+
// Keeping resolution outside the constructor avoids duplicating the env-var
46+
// lookup and ensures a single source of truth for mode normalization.
47+
const mode = config.mode ?? 'required';
48+
if (mode !== 'required' && mode !== 'optional') {
49+
throw new ClientAuthGateError(
50+
`client_auth_gate.mode must be 'required' or 'optional', got '${mode}'`,
51+
);
5652
}
57-
this.resolvedMode = resolvedMode ?? 'required';
53+
this.resolvedMode = mode;
5854
if (config.api_keys) {
5955
this.apiKeyStore = createApiKeyStore(config.api_keys, profileId, logger);
6056
}

src/transport/http-transport-client-auth.test.ts

Lines changed: 94 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -216,11 +216,19 @@ describe('Client auth gate (Phase 3) — session init integration', () => {
216216
};
217217
transport.setProfileContextProvider(async () => profileContext);
218218

219+
const logger = (transport as unknown as { logger: { warn: ReturnType<typeof vi.fn> } }).logger;
220+
const warnSpy = vi.spyOn(logger, 'warn');
221+
219222
const req = makeReq();
220223
const res = makeRes();
221224
await (transport as unknown as { handlePost: (r: unknown, s: unknown) => Promise<void> }).handlePost(req, res);
222225

223226
expect(res.statusCode).toBe(401);
227+
expect(res.body).toEqual(expect.objectContaining({ error: 'Unauthorized', message: 'Client authentication failed' }));
228+
expect(warnSpy).toHaveBeenCalledWith(
229+
'Client auth gate rejected session init',
230+
expect.objectContaining({ errorType: 'ClientAuthGateError', profileId: 'default' }),
231+
);
224232
});
225233

226234
// Scenario 5
@@ -269,11 +277,19 @@ describe('Client auth gate (Phase 3) — session init integration', () => {
269277
};
270278
transport.setProfileContextProvider(async () => profileContext);
271279

280+
const logger = (transport as unknown as { logger: { warn: ReturnType<typeof vi.fn> } }).logger;
281+
const warnSpy = vi.spyOn(logger, 'warn');
282+
272283
const req = makeReq();
273284
const res = makeRes();
274285
await (transport as unknown as { handlePost: (r: unknown, s: unknown) => Promise<void> }).handlePost(req, res);
275286

276287
expect(res.statusCode).toBe(401);
288+
expect(res.body).toEqual(expect.objectContaining({ error: 'Unauthorized', message: 'Client authentication failed' }));
289+
expect(warnSpy).toHaveBeenCalledWith(
290+
'Client auth gate rejected session init',
291+
expect.objectContaining({ errorType: 'ClientAuthGateError', profileId: 'default' }),
292+
);
277293
});
278294

279295
// Scenario 7
@@ -339,10 +355,14 @@ describe('Client auth gate (Phase 3) — session init integration', () => {
339355
await (transport as unknown as { handlePost: (r: unknown, s: unknown) => Promise<void> }).handlePost(req, res);
340356

341357
expect(res.statusCode).toBe(401);
342-
expect(res.body).toEqual(expect.objectContaining({ error: 'Unauthorized' }));
358+
expect(res.body).toEqual(expect.objectContaining({ error: 'Unauthorized', message: 'Client authentication failed' }));
343359
expect(warnSpy).toHaveBeenCalledWith(
344360
'Client auth gate rejected session init',
345-
expect.objectContaining({ errorType: 'unknown' }),
361+
expect.objectContaining({
362+
errorType: 'unknown',
363+
profileId: 'default',
364+
error: 'upstream store unreachable',
365+
}),
346366
);
347367
});
348368

@@ -370,10 +390,81 @@ describe('Client auth gate (Phase 3) — session init integration', () => {
370390
await (transport as unknown as { handlePost: (r: unknown, s: unknown) => Promise<void> }).handlePost(req, res);
371391

372392
expect(res.statusCode).toBe(401);
393+
expect(res.body).toEqual(expect.objectContaining({ error: 'Unauthorized', message: 'Client authentication failed' }));
373394
expect(warnSpy).toHaveBeenCalledWith(
374395
'Client auth gate rejected session init',
375-
expect.objectContaining({ errorType: 'ClientAuthGateError' }),
396+
expect.objectContaining({
397+
errorType: 'ClientAuthGateError',
398+
profileId: 'default',
399+
}),
400+
);
401+
});
402+
403+
// Scenario 11 — mode=optional + valid key → principal populated
404+
it('mode=optional + valid key -> 200 + session.clientPrincipal populated', async () => {
405+
// Pins that optional mode does NOT degrade all requests to anonymous — a client
406+
// that presents a valid key still receives a populated principal for audit/policy.
407+
const profileContext: HttpProfileContext = {
408+
profileId: 'default',
409+
client_auth_gate: {
410+
mode: 'optional',
411+
api_keys: {
412+
type: 'inline',
413+
keys: [{ key_from_env: VALID_KEY_ENV, subject: SUBJECT, scopes: ['read'] }],
414+
},
415+
} satisfies ClientAuthGateConfig,
416+
};
417+
transport.setProfileContextProvider(async () => profileContext);
418+
419+
const req = makeReq(`Bearer ${VALID_KEY}`);
420+
const res = makeRes();
421+
await (transport as unknown as { handlePost: (r: unknown, s: unknown) => Promise<void> }).handlePost(req, res);
422+
423+
expect(res.statusCode).toBe(200);
424+
const sessionId = res.headers['Mcp-Session-Id'];
425+
expect(sessionId).toBeTruthy();
426+
const session = getSession(transport, 'default', sessionId!) as
427+
| { clientPrincipal?: { authType: string; subject: string; scopes: string[] } }
428+
| undefined;
429+
expect(session).toBeDefined();
430+
expect(session!.clientPrincipal).toBeDefined();
431+
expect(session!.clientPrincipal!.authType).toBe('token');
432+
expect(session!.clientPrincipal!.subject).toBe(SUBJECT);
433+
expect(session!.clientPrincipal!.scopes).toEqual(['read']);
434+
});
435+
436+
// Scenario 12 — gate-initialized log fires with correct fields
437+
it('gate construction logs "Client auth gate initialized" with mode and hasApiKeys', async () => {
438+
// Pins that the initialization log (used by ops to confirm gate config at startup)
439+
// includes the correct structured fields.
440+
const profileContext: HttpProfileContext = {
441+
profileId: 'default',
442+
client_auth_gate: {
443+
mode: 'required',
444+
api_keys: {
445+
type: 'inline',
446+
keys: [{ key_from_env: VALID_KEY_ENV, subject: SUBJECT }],
447+
},
448+
},
449+
};
450+
transport.setProfileContextProvider(async () => profileContext);
451+
452+
const logger = (transport as unknown as { logger: { info: ReturnType<typeof vi.fn> } }).logger;
453+
const infoSpy = vi.spyOn(logger, 'info');
454+
455+
// Drive a request to trigger getProfileState() and gate construction.
456+
const req = makeReq(`Bearer ${VALID_KEY}`);
457+
const res = makeRes();
458+
await (transport as unknown as { handlePost: (r: unknown, s: unknown) => Promise<void> }).handlePost(req, res);
459+
460+
const initCall = infoSpy.mock.calls.find(
461+
(c) => typeof c[0] === 'string' && c[0].includes('Client auth gate initialized'),
376462
);
463+
expect(initCall).toBeDefined();
464+
const logFields = initCall![1] as Record<string, unknown>;
465+
expect(logFields['mode']).toBe('required');
466+
expect(logFields['hasApiKeys']).toBe(true);
467+
expect(logFields['profileId']).toBe('default');
377468
});
378469

379470
// Scenario 10 — session-creation log includes clientSubject + clientAuthType

src/transport/http-transport.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -533,13 +533,24 @@ export class HttpTransport {
533533
// api_keys env vars. In the profile-loader path this is a no-op (validator
534534
// already ran at load time and mode is a literal string). For direct
535535
// HttpTransport construction the call provides the same fail-fast guarantees.
536-
const gateConfig = resolveClientAuthGateConfig(context.client_auth_gate);
537-
state.clientAuthGate = new ClientAuthGate(profileId, gateConfig, this.logger);
538-
this.logger.info('Client auth gate initialized', {
539-
profileId,
540-
mode: gateConfig.mode,
541-
hasApiKeys: !!gateConfig.api_keys,
542-
});
536+
// Wrap in try/catch so a misconfigured gate produces a clear error log and a
537+
// descriptive 500 (via the handlePost outer catch) rather than a generic one.
538+
try {
539+
const gateConfig = resolveClientAuthGateConfig(context.client_auth_gate);
540+
state.clientAuthGate = new ClientAuthGate(profileId, gateConfig, this.logger);
541+
this.logger.info('Client auth gate initialized', {
542+
profileId,
543+
mode: gateConfig.mode,
544+
hasApiKeys: !!gateConfig.api_keys,
545+
});
546+
} catch (err) {
547+
this.logger.error(
548+
'Client auth gate configuration error — profile will be unavailable',
549+
err instanceof Error ? err : new Error(String(err)),
550+
{ profileId },
551+
);
552+
throw err;
553+
}
543554
}
544555

545556
this.profileStates.set(profileId, state);
@@ -2791,6 +2802,7 @@ export class HttpTransport {
27912802
profileId: requestProfileId,
27922803
error: err instanceof Error ? err.message : String(err),
27932804
errorType: isClientAuthGateError ? 'ClientAuthGateError' : 'unknown',
2805+
...(isClientAuthGateError ? {} : { errorStack: err instanceof Error ? err.stack : undefined }),
27942806
});
27952807
res.status(HTTP_STATUS.UNAUTHORIZED).json({
27962808
error: 'Unauthorized',
@@ -3020,6 +3032,10 @@ export class HttpTransport {
30203032
status = HTTP_STATUS.TOO_MANY_REQUESTS;
30213033
errorLabel = 'Too Many Requests';
30223034
message = `Rate limit exceeded: ${error.message} (correlation ID: ${correlationId})`;
3035+
} else if (error instanceof ClientAuthGateError) {
3036+
status = 500;
3037+
errorLabel = 'Gateway Configuration Error';
3038+
message = `Client auth gate misconfigured: ${error.message} (correlation ID: ${correlationId})`;
30233039
}
30243040

30253041
res.status(status).json({ error: errorLabel, message, correlationId });

0 commit comments

Comments
 (0)