@mcp-abap-adt/core 6.7.0 → 6.9.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +38 -0
  3. package/dist/handlers/system/readonly/handleSearchSource.d.ts +1 -1
  4. package/dist/handlers/system/readonly/handleSearchSource.d.ts.map +1 -1
  5. package/dist/handlers/system/readonly/handleSearchSource.js +2 -2
  6. package/dist/handlers/system/readonly/handleSearchSource.js.map +1 -1
  7. package/dist/lib/config/applyAuthFields.d.ts +8 -0
  8. package/dist/lib/config/applyAuthFields.d.ts.map +1 -0
  9. package/dist/lib/config/applyAuthFields.js +30 -0
  10. package/dist/lib/config/applyAuthFields.js.map +1 -0
  11. package/dist/lib/config/parseAuthType.d.ts +4 -0
  12. package/dist/lib/config/parseAuthType.d.ts.map +1 -0
  13. package/dist/lib/config/parseAuthType.js +22 -0
  14. package/dist/lib/config/parseAuthType.js.map +1 -0
  15. package/dist/lib/config.d.ts.map +1 -1
  16. package/dist/lib/config.js +6 -15
  17. package/dist/lib/config.js.map +1 -1
  18. package/dist/lib/search-source/orchestrator.d.ts +1 -0
  19. package/dist/lib/search-source/orchestrator.d.ts.map +1 -1
  20. package/dist/lib/search-source/orchestrator.js +10 -3
  21. package/dist/lib/search-source/orchestrator.js.map +1 -1
  22. package/dist/lib/search-source/packageResolver.d.ts +13 -0
  23. package/dist/lib/search-source/packageResolver.d.ts.map +1 -0
  24. package/dist/lib/search-source/packageResolver.js +63 -0
  25. package/dist/lib/search-source/packageResolver.js.map +1 -0
  26. package/dist/lib/utils.d.ts.map +1 -1
  27. package/dist/lib/utils.js +11 -16
  28. package/dist/lib/utils.js.map +1 -1
  29. package/docs/superpowers/plans/2026-05-23-cert-kerberos-auth.md +652 -0
  30. package/docs/superpowers/plans/innovations-from-sap-adt-mcp.md +103 -0
  31. package/docs/superpowers/specs/2026-05-23-cert-kerberos-auth-design.md +149 -0
  32. package/docs/user-guide/AVAILABLE_TOOLS.md +2 -2
  33. package/docs/user-guide/AVAILABLE_TOOLS_COMPACT.md +1 -1
  34. package/docs/user-guide/AVAILABLE_TOOLS_HIGH.md +1 -1
  35. package/docs/user-guide/AVAILABLE_TOOLS_LEGACY.md +2 -2
  36. package/docs/user-guide/AVAILABLE_TOOLS_LOW.md +1 -1
  37. package/docs/user-guide/AVAILABLE_TOOLS_READONLY.md +2 -2
  38. package/package.json +3 -3
@@ -0,0 +1,652 @@
1
+ # Certificate (mTLS) + Kerberos Auth — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add `certificate` (file-based mTLS) and `kerberos` (SPNEGO/Negotiate, single-leg) authentication to the ADT connection stack, cross-platform, on-prem HTTP only.
6
+
7
+ **Architecture:** **Both new types bypass `auth-broker` and `auth-providers`** (the broker is only for OAuth + browser-callback auth; cert/kerberos are non-interactive, connection-level). Certificate = `CertificateAbapConnection` injecting mTLS material into the axios `httpsAgent` via a new `getHttpsAgentOptions()` hook, with file I/O behind an injectable `ICertificateMaterialLoader`. Kerberos = `KerberosAbapConnection` that generates the Negotiate token **locally** via a lazy/optional `kerberos` npm wrapper living in the connection package, sends it on the first request, then reuses the SAP session cookie. No `ITokenRefresher`, no `BaseTokenProvider`.
8
+
9
+ **Tech Stack:** TypeScript, axios, `node:https` Agent, optional native `kerberos` npm. **Three repos**, bottom-up: `interfaces` → `connection` → `mcp-abap-adt` (server).
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-05-23-cert-kerberos-auth-design.md` (this repo).
12
+
13
+ **Decisions locked:** PEM **and** PFX both supported. Single-leg Negotiate only. cert+kerberos bypass broker/providers.
14
+
15
+ **Worktree discipline:** Each phase runs in its own repo, on branch `feat/cert-kerberos-auth` (`git worktree add .worktrees/cert-kerberos-auth -b feat/cert-kerberos-auth`; `.worktrees/` gitignored). Never commit to `master`/`main` directly.
16
+
17
+ **PUBLISH GATE (critical):** The agent **NEVER runs `npm publish`** — the **user publishes**. Packages depend via **published npm imports, not local links**, so a consumer cannot be tested/bumped against unpublished changes. Each phase ends: implement → commit → PR/merge → **hand off to user to publish; wait for the confirmed version** → bump consumer → next phase.
18
+
19
+ ---
20
+
21
+ ## Phase 1 — `@mcp-abap-adt/interfaces` — ✅ DONE
22
+
23
+ Implemented, reviewed (spec + quality), committed on `feat/cert-kerberos-auth` in `~/prj/mcp-abap-adt-interfaces`:
24
+ - `SapAuthType` union → `'basic' | 'jwt' | 'saml' | 'certificate' | 'kerberos'`.
25
+ - `ISapConfig` + `certPath?`, `certKeyPath?`, `certPfxPath?`, `certPassphrase?`, `kerberosSpn?`, `kerberosService?`.
26
+ - New `src/auth/ICertificateMaterialLoader.ts` (`ICertificateMaterial`, `ICertificateMaterialLoader`), exported from `src/index.ts`.
27
+ - `IConnectionConfig.authType` **left as `'basic'|'jwt'|'saml'`** (NOT widened) — it is the broker's auth surface and cert/kerberos bypass the broker.
28
+ - Build plumbing: `tsconfig.build.json` excludes the compile-only `src/__typechecks__/` from `dist`.
29
+
30
+ **Remaining: publish gate** — user publishes `interfaces`; note the version `<IFACE_VER>` for Phases 2–3.
31
+
32
+ ---
33
+
34
+ ## Phase 2 — `@mcp-abap-adt/connection` (`~/prj/mcp-abap-connection`)
35
+
36
+ First: in the worktree, `npm i @mcp-abap-adt/interfaces@<IFACE_VER>` (the user-published version from Phase 1). Confirm baseline tests pass before starting.
37
+
38
+ Repo paths below are relative to `~/prj/mcp-abap-connection`. Test runner: this repo HAS a test runner (check `package.json` `scripts.test` — jest per `jest.config.js`). Use it.
39
+
40
+ ### Task 2.1: `getHttpsAgentOptions()` hook on `AbstractAbapConnection`
41
+
42
+ **Files:**
43
+ - Modify: `src/connection/AbstractAbapConnection.ts` — `getAxiosInstance()` (~line 743) + add a protected hook.
44
+ - Test: `src/__tests__/connection/httpsAgentHook.test.ts`
45
+
46
+ - [ ] **Step 1: Write the failing test** (concrete subclass overriding the hook; assert options reach the Agent):
47
+
48
+ ```ts
49
+ import { Agent } from 'node:https';
50
+ import { AbstractAbapConnection } from '../../connection/AbstractAbapConnection';
51
+
52
+ class TestConn extends (AbstractAbapConnection as any) {
53
+ protected getHttpsAgentOptions() { return { cert: 'C', key: 'K' }; }
54
+ async connect() {}
55
+ protected buildAuthorizationHeader() { return ''; }
56
+ getAgent(): Agent { return (this as any).getAxiosInstance().defaults.httpsAgent; }
57
+ }
58
+
59
+ test('getHttpsAgentOptions merges into the https.Agent', () => {
60
+ const c = new TestConn({ url: 'https://h:44300', authType: 'basic' } as any, null);
61
+ const agent = c.getAgent();
62
+ expect((agent as any).options.cert).toBe('C');
63
+ expect((agent as any).options.key).toBe('K');
64
+ });
65
+ ```
66
+
67
+ - [ ] **Step 2: Run, verify FAIL** (hook missing). Run the repo's test command targeting this file.
68
+
69
+ - [ ] **Step 3: Implement.** Add the hook method near other protected methods:
70
+
71
+ ```ts
72
+ /** Subclasses override to inject extra https.Agent options (e.g. mTLS cert/key/pfx). */
73
+ protected getHttpsAgentOptions(): import('node:https').AgentOptions {
74
+ return {};
75
+ }
76
+ ```
77
+
78
+ Change `getAxiosInstance()` to merge them:
79
+
80
+ ```ts
81
+ this.axiosInstance = axios.create({
82
+ httpsAgent: new Agent({
83
+ rejectUnauthorized,
84
+ ...this.getHttpsAgentOptions(),
85
+ }),
86
+ });
87
+ ```
88
+
89
+ - [ ] **Step 4: Run, verify PASS.**
90
+ - [ ] **Step 5: Commit** `feat(connection): add getHttpsAgentOptions hook to AbstractAbapConnection`.
91
+
92
+ ### Task 2.2: `FileCertificateMaterialLoader`
93
+
94
+ **Files:**
95
+ - Create: `src/auth/FileCertificateMaterialLoader.ts`
96
+ - Test: `src/__tests__/auth/FileCertificateMaterialLoader.test.ts`
97
+
98
+ - [ ] **Step 1: Write the failing test** (temp dir for PEM/PFX):
99
+
100
+ ```ts
101
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
102
+ import { join } from 'node:path';
103
+ import { tmpdir } from 'node:os';
104
+ import { FileCertificateMaterialLoader } from '../../auth/FileCertificateMaterialLoader';
105
+
106
+ let dir: string;
107
+ beforeAll(() => {
108
+ dir = mkdtempSync(join(tmpdir(), 'certtest-'));
109
+ writeFileSync(join(dir, 'c.crt'), 'CERT');
110
+ writeFileSync(join(dir, 'c.key'), 'KEY');
111
+ writeFileSync(join(dir, 'c.pfx'), Buffer.from([1, 2, 3]));
112
+ });
113
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));
114
+
115
+ const loader = new FileCertificateMaterialLoader();
116
+
117
+ test('loads PEM cert+key', async () => {
118
+ const m = await loader.load({ url: 'https://h', authType: 'certificate',
119
+ certPath: join(dir, 'c.crt'), certKeyPath: join(dir, 'c.key') } as any);
120
+ expect(m.cert?.toString()).toBe('CERT');
121
+ expect(m.key?.toString()).toBe('KEY');
122
+ });
123
+
124
+ test('loads PFX with passphrase', async () => {
125
+ const m = await loader.load({ url: 'https://h', authType: 'certificate',
126
+ certPfxPath: join(dir, 'c.pfx'), certPassphrase: 'pw' } as any);
127
+ expect(m.pfx).toBeInstanceOf(Buffer);
128
+ expect(m.passphrase).toBe('pw');
129
+ });
130
+
131
+ test('throws when both PEM and PFX given', async () => {
132
+ await expect(loader.load({ url: 'https://h', authType: 'certificate',
133
+ certPath: join(dir, 'c.crt'), certPfxPath: join(dir, 'c.pfx') } as any)).rejects.toThrow();
134
+ });
135
+ ```
136
+
137
+ - [ ] **Step 2: Run, verify FAIL.**
138
+ - [ ] **Step 3: Implement** `src/auth/FileCertificateMaterialLoader.ts`:
139
+
140
+ ```ts
141
+ import { readFile } from 'node:fs/promises';
142
+ import type {
143
+ ICertificateMaterial,
144
+ ICertificateMaterialLoader,
145
+ ISapConfig,
146
+ } from '@mcp-abap-adt/interfaces';
147
+
148
+ export class FileCertificateMaterialLoader implements ICertificateMaterialLoader {
149
+ async load(config: ISapConfig): Promise<ICertificateMaterial> {
150
+ const hasPem = !!(config.certPath || config.certKeyPath);
151
+ const hasPfx = !!config.certPfxPath;
152
+ if (hasPem && hasPfx) {
153
+ throw new Error('Certificate auth: provide either PEM (certPath+certKeyPath) OR certPfxPath, not both.');
154
+ }
155
+ if (hasPfx) {
156
+ return { pfx: await readFile(config.certPfxPath as string), passphrase: config.certPassphrase };
157
+ }
158
+ if (config.certPath && config.certKeyPath) {
159
+ return {
160
+ cert: await readFile(config.certPath),
161
+ key: await readFile(config.certKeyPath),
162
+ passphrase: config.certPassphrase,
163
+ };
164
+ }
165
+ throw new Error('Certificate auth requires certPfxPath OR (certPath AND certKeyPath).');
166
+ }
167
+ }
168
+ ```
169
+
170
+ - [ ] **Step 4: Run, verify PASS.**
171
+ - [ ] **Step 5: Commit** `feat(connection): file-based certificate material loader`.
172
+
173
+ ### Task 2.3: `CertificateAbapConnection`
174
+
175
+ **Files:**
176
+ - Create: `src/connection/CertificateAbapConnection.ts`
177
+ - Test: `src/__tests__/connection/CertificateAbapConnection.test.ts`
178
+
179
+ - [ ] **Step 1: Write the failing test** (inject fake loader; check agent options + empty header + rfc guard):
180
+
181
+ ```ts
182
+ import { CertificateAbapConnection } from '../../connection/CertificateAbapConnection';
183
+ import type { ICertificateMaterialLoader } from '@mcp-abap-adt/interfaces';
184
+
185
+ const fakeLoader: ICertificateMaterialLoader = {
186
+ load: async () => ({ cert: 'C', key: 'K', passphrase: 'pw' }),
187
+ };
188
+ const cfg = { url: 'https://h:44300', authType: 'certificate', client: '100',
189
+ certPath: '/x.crt', certKeyPath: '/x.key' } as any;
190
+
191
+ test('no Authorization header (mTLS identifies user)', () => {
192
+ const c = new CertificateAbapConnection(cfg, null, undefined, fakeLoader);
193
+ expect((c as any).buildAuthorizationHeader()).toBe('');
194
+ });
195
+
196
+ test('loaded material reaches getHttpsAgentOptions after material load', async () => {
197
+ const c = new CertificateAbapConnection(cfg, null, undefined, fakeLoader);
198
+ await (c as any).ensureMaterial();
199
+ const opts = (c as any).getHttpsAgentOptions();
200
+ expect(opts.cert).toBe('C');
201
+ expect(opts.key).toBe('K');
202
+ });
203
+
204
+ test('rejects connectionType rfc', () => {
205
+ expect(() => new CertificateAbapConnection({ ...cfg, connectionType: 'rfc' }, null, undefined, fakeLoader))
206
+ .toThrow(/rfc/i);
207
+ });
208
+ ```
209
+
210
+ - [ ] **Step 2: Run, verify FAIL.**
211
+ - [ ] **Step 3: Implement** `src/connection/CertificateAbapConnection.ts`:
212
+
213
+ ```ts
214
+ import type { AgentOptions } from 'node:https';
215
+ import type { ICertificateMaterial, ICertificateMaterialLoader } from '@mcp-abap-adt/interfaces';
216
+ import type { SapConfig } from '../config/sapConfig.js';
217
+ import type { ILogger } from '../logger.js';
218
+ import { AbstractAbapConnection } from './AbstractAbapConnection.js';
219
+ import { FileCertificateMaterialLoader } from '../auth/FileCertificateMaterialLoader.js';
220
+
221
+ /** Client-certificate (mTLS) authentication. Credential lives on the TLS agent. */
222
+ export class CertificateAbapConnection extends AbstractAbapConnection {
223
+ private loader: ICertificateMaterialLoader;
224
+ private material: ICertificateMaterial | null = null;
225
+
226
+ constructor(config: SapConfig, logger?: ILogger | null, sessionId?: string, loader?: ICertificateMaterialLoader) {
227
+ CertificateAbapConnection.validateConfig(config);
228
+ super(config, logger || null, sessionId);
229
+ this.loader = loader ?? new FileCertificateMaterialLoader();
230
+ }
231
+
232
+ private static validateConfig(config: SapConfig): void {
233
+ if (config.authType !== 'certificate') {
234
+ throw new Error(`Certificate connection expects authType "certificate", got "${config.authType}"`);
235
+ }
236
+ if (config.connectionType === 'rfc') {
237
+ throw new Error('Certificate auth is not supported with connectionType "rfc".');
238
+ }
239
+ const hasPem = !!(config.certPath && config.certKeyPath);
240
+ const hasPfx = !!config.certPfxPath;
241
+ if (!hasPem && !hasPfx) {
242
+ throw new Error('Certificate auth requires certPfxPath OR (certPath AND certKeyPath).');
243
+ }
244
+ if ((config.certPath || config.certKeyPath) && config.certPfxPath) {
245
+ throw new Error('Certificate auth: provide either PEM pair OR PFX, not both.');
246
+ }
247
+ }
248
+
249
+ private cfg(): SapConfig {
250
+ return (this as unknown as { config: SapConfig }).config;
251
+ }
252
+
253
+ /** Load cert material before the first request so the cached agent carries the client cert. */
254
+ private async ensureMaterial(): Promise<void> {
255
+ if (!this.material) this.material = await this.loader.load(this.cfg());
256
+ }
257
+
258
+ async connect(): Promise<void> {
259
+ await this.ensureMaterial();
260
+ const baseUrl = await this.getBaseUrl();
261
+ const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
262
+ try {
263
+ const token = await this.fetchCsrfToken(discoveryUrl, 3, 1000);
264
+ this.setCsrfToken(token);
265
+ } catch (error) {
266
+ this.logger?.warn(
267
+ `[WARN] CertificateAbapConnection - connect deferred: ${error instanceof Error ? error.message : String(error)}`,
268
+ );
269
+ }
270
+ }
271
+
272
+ protected getHttpsAgentOptions(): AgentOptions {
273
+ if (!this.material) return {};
274
+ const { cert, key, pfx, passphrase } = this.material;
275
+ return { cert, key, pfx, passphrase };
276
+ }
277
+
278
+ protected buildAuthorizationHeader(): string {
279
+ return '';
280
+ }
281
+ }
282
+ ```
283
+
284
+ > `getConfig()`/`getBaseUrl()`/`fetchCsrfToken()`/`setCsrfToken()` exist on `AbstractAbapConnection` (used by Saml/Jwt). The private `config` is accessed via the `cfg()` helper because the base stores it privately; if the base exposes a `getConfig()` accessor, prefer that and drop `cfg()`.
285
+
286
+ - [ ] **Step 4: Run, verify PASS.**
287
+ - [ ] **Step 5: Commit** `feat(connection): CertificateAbapConnection (mTLS via https agent)`.
288
+
289
+ ### Task 2.4: `kerberosSpnego` wrapper (lazy, optional native dep)
290
+
291
+ **Files:**
292
+ - Create: `src/auth/kerberosSpnego.ts`
293
+ - Test: `src/__tests__/auth/kerberosSpnego.test.ts`
294
+ - Modify: `package.json` (`optionalDependencies`)
295
+
296
+ - [ ] **Step 1: Add optional dependency** to `package.json`:
297
+
298
+ ```json
299
+ "optionalDependencies": {
300
+ "kerberos": "^2.1.0"
301
+ }
302
+ ```
303
+
304
+ - [ ] **Step 2: Write the failing test** (mock the native module; assert token returned):
305
+
306
+ ```ts
307
+ test('generateSpnegoToken returns a base64 token for an SPN', async () => {
308
+ jest.doMock('kerberos', () => ({
309
+ initializeClient: jest.fn(async () => ({
310
+ step: jest.fn(async (_c: string) => undefined),
311
+ response: 'YIIBexyz==',
312
+ })),
313
+ }), { virtual: true });
314
+ const { generateSpnegoToken } = await import('../../auth/kerberosSpnego');
315
+ const token = await generateSpnegoToken('HTTP@sap-host.corp');
316
+ expect(token).toBe('YIIBexyz==');
317
+ });
318
+ ```
319
+
320
+ > Use the repo's mocking idiom (jest shown; the `{ virtual: true }` lets the test run even if `kerberos` isn't installed). Match `jest.config.js` ESM/CJS conventions.
321
+
322
+ - [ ] **Step 3: Run, verify FAIL.**
323
+ - [ ] **Step 4: Implement** `src/auth/kerberosSpnego.ts`:
324
+
325
+ ```ts
326
+ /** Thin, lazily-loaded wrapper over the optional `kerberos` native package. */
327
+ export async function generateSpnegoToken(spn: string): Promise<string> {
328
+ let kerberos: typeof import('kerberos');
329
+ try {
330
+ kerberos = await import('kerberos');
331
+ } catch {
332
+ throw new Error(
333
+ 'Kerberos authentication requires the optional "kerberos" package. ' +
334
+ 'Install it (needs GSSAPI dev libs on Linux / build tools on Windows): npm i kerberos',
335
+ );
336
+ }
337
+ const client = await kerberos.initializeClient(spn, {});
338
+ await client.step(''); // single-leg: initial AP-REQ
339
+ const token = (client as unknown as { response?: string }).response;
340
+ if (!token) {
341
+ throw new Error('Kerberos: no SPNEGO token produced (no TGT? run kinit, or check SPN).');
342
+ }
343
+ return token;
344
+ }
345
+ ```
346
+
347
+ > **Verify the `kerberos` API** against the installed package's types before finalizing: confirm `initializeClient(spn, options)` and that the produced token is on `client.response` after `step()`. Adjust the accessor if the real API differs (some versions return the token from `step()`); keep the public `generateSpnegoToken(spn): Promise<string>` shape stable so the connection and test are unaffected.
348
+
349
+ - [ ] **Step 5: Run, verify PASS.**
350
+ - [ ] **Step 6: Commit** `feat(connection): lazy SPNEGO token wrapper over optional kerberos`.
351
+
352
+ ### Task 2.5: `KerberosAbapConnection`
353
+
354
+ **Files:**
355
+ - Create: `src/connection/KerberosAbapConnection.ts`
356
+ - Test: `src/__tests__/connection/KerberosAbapConnection.test.ts`
357
+
358
+ - [ ] **Step 1: Write the failing test** (mock the spnego wrapper; assert Negotiate header logic + rfc guard):
359
+
360
+ ```ts
361
+ jest.mock('../../auth/kerberosSpnego', () => ({
362
+ generateSpnegoToken: jest.fn(async (_spn: string) => 'NEG_TOKEN'),
363
+ }));
364
+ import { KerberosAbapConnection } from '../../connection/KerberosAbapConnection';
365
+
366
+ const cfg = { url: 'https://h:44300', authType: 'kerberos', client: '100', kerberosSpn: 'HTTP@h.corp' } as any;
367
+
368
+ test('emits Authorization: Negotiate <token> before a cookie exists', async () => {
369
+ const c = new KerberosAbapConnection(cfg, null, undefined);
370
+ await (c as any).ensureToken();
371
+ expect((c as any).buildAuthorizationHeader()).toBe('Negotiate NEG_TOKEN');
372
+ });
373
+
374
+ test('derives SPN from url when kerberosSpn absent', async () => {
375
+ const c = new KerberosAbapConnection({ ...cfg, kerberosSpn: undefined }, null, undefined);
376
+ await (c as any).ensureToken();
377
+ const spnego = await import('../../auth/kerberosSpnego');
378
+ expect((spnego.generateSpnegoToken as any)).toHaveBeenCalledWith('HTTP@h');
379
+ });
380
+
381
+ test('rejects connectionType rfc', () => {
382
+ expect(() => new KerberosAbapConnection({ ...cfg, connectionType: 'rfc' }, null, undefined)).toThrow(/rfc/i);
383
+ });
384
+ ```
385
+
386
+ - [ ] **Step 2: Run, verify FAIL.**
387
+ - [ ] **Step 3: Implement** `src/connection/KerberosAbapConnection.ts`:
388
+
389
+ ```ts
390
+ import type { SapConfig } from '../config/sapConfig.js';
391
+ import type { ILogger } from '../logger.js';
392
+ import { AbstractAbapConnection } from './AbstractAbapConnection.js';
393
+ import { generateSpnegoToken } from '../auth/kerberosSpnego.js';
394
+
395
+ /** Kerberos / SPNEGO single-leg auth: send Negotiate token, reuse the resulting SAP session cookie. */
396
+ export class KerberosAbapConnection extends AbstractAbapConnection {
397
+ private spn: string;
398
+ private currentToken = '';
399
+
400
+ constructor(config: SapConfig, logger?: ILogger | null, sessionId?: string) {
401
+ KerberosAbapConnection.validateConfig(config);
402
+ super(config, logger || null, sessionId);
403
+ this.spn = config.kerberosSpn ?? `${config.kerberosService ?? 'HTTP'}@${new URL(config.url).hostname}`;
404
+ }
405
+
406
+ private static validateConfig(config: SapConfig): void {
407
+ if (config.authType !== 'kerberos') {
408
+ throw new Error(`Kerberos connection expects authType "kerberos", got "${config.authType}"`);
409
+ }
410
+ if (config.connectionType === 'rfc') {
411
+ throw new Error('Kerberos auth is not supported with connectionType "rfc".');
412
+ }
413
+ }
414
+
415
+ private async ensureToken(): Promise<void> {
416
+ if (!this.currentToken) this.currentToken = await generateSpnegoToken(this.spn);
417
+ }
418
+
419
+ async connect(): Promise<void> {
420
+ await this.ensureToken();
421
+ const baseUrl = await this.getBaseUrl();
422
+ const discoveryUrl = `${baseUrl}/sap/bc/adt/discovery`;
423
+ try {
424
+ const token = await this.fetchCsrfToken(discoveryUrl, 3, 1000);
425
+ this.setCsrfToken(token);
426
+ // After the first authenticated round-trip SAP issues a session cookie which the base
427
+ // class captures and reuses; later requests need no new Negotiate token.
428
+ } catch (error) {
429
+ this.logger?.warn(
430
+ `[WARN] KerberosAbapConnection - connect deferred: ${error instanceof Error ? error.message : String(error)}`,
431
+ );
432
+ }
433
+ }
434
+
435
+ protected buildAuthorizationHeader(): string {
436
+ if (this.getCookies()) return ''; // cookie carries auth after first round-trip
437
+ return this.currentToken ? `Negotiate ${this.currentToken}` : '';
438
+ }
439
+ }
440
+ ```
441
+
442
+ > The default `getAuthHeaders()` calls `buildAuthorizationHeader()` and sets `Authorization` only when non-empty (confirm against `AbstractAbapConnection`; `SamlAbapConnection` relies on the same empty-header behavior). `getCookies()` is inherited. If the SPNEGO token must be generated lazily on the very first request rather than only in `connect()`, also call `ensureToken()` at the top of an overridden `getAuthHeaders()` — verify which entrypoint runs first and add the guard if needed. **NTLM reject:** if a live `WWW-Authenticate` response offers only `NTLM` (challenge decodes to prefix `TlRMTVNTUAA`), throw a clear error instead of looping — add this guard when wiring the 401 path (test with a mocked 401 carrying `www-authenticate: NTLM`).
443
+
444
+ - [ ] **Step 4: Run, verify PASS.**
445
+ - [ ] **Step 5: Commit** `feat(connection): KerberosAbapConnection (single-leg Negotiate)`.
446
+
447
+ ### Task 2.6: Wire the factory + config signature
448
+
449
+ **Files:**
450
+ - Modify: `src/connection/connectionFactory.ts`
451
+ - Modify: `src/config/sapConfig.ts` (`sapConfigSignature`)
452
+ - Test: `src/__tests__/connectionFactory.test.ts` (extend)
453
+
454
+ - [ ] **Step 1: Write failing factory tests:**
455
+
456
+ ```ts
457
+ test('creates CertificateAbapConnection for authType certificate', () => {
458
+ const c = createAbapConnection({ url: 'https://h', authType: 'certificate',
459
+ certPath: '/c', certKeyPath: '/k' } as any, null);
460
+ expect(c.constructor.name).toBe('CertificateAbapConnection');
461
+ });
462
+ test('creates KerberosAbapConnection for authType kerberos', () => {
463
+ const c = createAbapConnection({ url: 'https://h', authType: 'kerberos', kerberosSpn: 'HTTP@h' } as any, null);
464
+ expect(c.constructor.name).toBe('KerberosAbapConnection');
465
+ });
466
+ test('throws for certificate + rfc', () => {
467
+ expect(() => createAbapConnection({ url: 'https://h', authType: 'certificate', connectionType: 'rfc',
468
+ certPath: '/c', certKeyPath: '/k' } as any, null)).toThrow(/rfc/i);
469
+ });
470
+ ```
471
+
472
+ - [ ] **Step 2: Run, verify FAIL.**
473
+ - [ ] **Step 3: Implement.** Add imports + an optional `certLoader` in `options` + cases. The kerberos case takes NO tokenRefresher (self-contained):
474
+
475
+ ```ts
476
+ import { CertificateAbapConnection } from './CertificateAbapConnection.js';
477
+ import { KerberosAbapConnection } from './KerberosAbapConnection.js';
478
+ import type { ICertificateMaterialLoader } from '@mcp-abap-adt/interfaces';
479
+
480
+ export function createAbapConnection(
481
+ config: SapConfig,
482
+ logger?: ILogger | null,
483
+ sessionId?: string,
484
+ tokenRefresher?: ITokenRefresher,
485
+ options?: { skipSessionType?: boolean; certLoader?: ICertificateMaterialLoader },
486
+ ): AbapConnection {
487
+ if (config.connectionType === 'rfc') {
488
+ if (config.authType === 'certificate' || config.authType === 'kerberos') {
489
+ throw new Error(`authType "${config.authType}" is not supported with connectionType "rfc".`);
490
+ }
491
+ return new RfcAbapConnection(config, logger);
492
+ }
493
+
494
+ switch (config.authType) {
495
+ case 'basic':
496
+ return new BaseAbapConnection(config, logger, sessionId, options);
497
+ case 'jwt':
498
+ return new JwtAbapConnection(config, logger, sessionId, tokenRefresher);
499
+ case 'saml':
500
+ return new SamlAbapConnection(config, logger, sessionId, options);
501
+ case 'certificate':
502
+ return new CertificateAbapConnection(config, logger, sessionId, options?.certLoader);
503
+ case 'kerberos':
504
+ return new KerberosAbapConnection(config, logger, sessionId);
505
+ default:
506
+ throw new Error(`Unsupported SAP authentication type: ${config.authType}`);
507
+ }
508
+ }
509
+ ```
510
+
511
+ > Verify no existing caller passes a 5th positional arg in a way the widened `options` shape breaks.
512
+
513
+ - [ ] **Step 4: Extend `sapConfigSignature()`** in `src/config/sapConfig.ts` — add to the returned object (paths/flags only, never secrets):
514
+
515
+ ```ts
516
+ certPath: config.certPath ?? null,
517
+ certKeyPath: config.certKeyPath ?? null,
518
+ certPfxPath: config.certPfxPath ?? null,
519
+ certPassphrase: config.certPassphrase ? 'set' : null,
520
+ kerberosSpn: config.kerberosSpn ?? null,
521
+ ```
522
+
523
+ - [ ] **Step 5: Run all connection tests, verify PASS.**
524
+ - [ ] **Step 6: Commit** `feat(connection): route certificate/kerberos in factory; extend config signature`.
525
+
526
+ ### Task 2.7: Release connection (publish gate — user publishes)
527
+
528
+ - [ ] **Step 1:** Bump version + CHANGELOG, commit. `npm run build` + full test suite green. **Do NOT publish.**
529
+ - [ ] **Step 2:** PR + merge.
530
+ - [ ] **Step 3: HAND OFF — ask the user to publish; record the confirmed version `<CONN_VER>` before Phase 3.**
531
+
532
+ ---
533
+
534
+ ## Phase 3 — `mcp-abap-adt` (server, this repo)
535
+
536
+ First: in the worktree, bump `@mcp-abap-adt/interfaces@<IFACE_VER>` and `@mcp-abap-adt/connection@<CONN_VER>` to the user-confirmed published versions. Work on branch `feat/cert-kerberos-auth`.
537
+
538
+ ### Task 3.1: Shared `parseAuthType()` accepting new types
539
+
540
+ **Files:**
541
+ - Create: `src/lib/config/parseAuthType.ts`
542
+ - Modify: `src/lib/config.ts` (~59-75), `src/lib/utils.ts` (~1892-1900)
543
+ - Test: `src/__tests__/lib/parseAuthType.test.ts`
544
+
545
+ - [ ] **Step 1: Write failing test:**
546
+
547
+ ```ts
548
+ import { parseAuthType } from '../../lib/config/parseAuthType';
549
+
550
+ test('maps env to auth types', () => {
551
+ expect(parseAuthType({ SAP_JWT_TOKEN: 'x' })).toBe('jwt');
552
+ expect(parseAuthType({ SAP_AUTH_TYPE: 'xsuaa' })).toBe('jwt');
553
+ expect(parseAuthType({ SAP_AUTH_TYPE: 'certificate' })).toBe('certificate');
554
+ expect(parseAuthType({ SAP_AUTH_TYPE: 'kerberos' })).toBe('kerberos');
555
+ expect(parseAuthType({ SAP_AUTH_TYPE: 'saml' })).toBe('saml');
556
+ expect(parseAuthType({})).toBe('basic');
557
+ });
558
+ ```
559
+
560
+ - [ ] **Step 2: Run, verify FAIL.**
561
+ - [ ] **Step 3: Implement** `src/lib/config/parseAuthType.ts`:
562
+
563
+ ```ts
564
+ import type { SapAuthType } from '@mcp-abap-adt/interfaces';
565
+
566
+ export function parseAuthType(env: NodeJS.ProcessEnv | Record<string, string | undefined>): SapAuthType {
567
+ if (env.SAP_JWT_TOKEN) return 'jwt';
568
+ const raw = env.SAP_AUTH_TYPE?.trim().toLowerCase();
569
+ if (!raw) return 'basic';
570
+ if (raw === 'xsuaa') return 'jwt';
571
+ if (raw === 'basic' || raw === 'jwt' || raw === 'saml' || raw === 'certificate' || raw === 'kerberos') {
572
+ return raw;
573
+ }
574
+ return 'basic';
575
+ }
576
+ ```
577
+
578
+ - [ ] **Step 4: Replace both inline parsers** in `config.ts` (~59-75) and `utils.ts` (~1892-1900) with `parseAuthType(process.env)`. Remove the duplicated literal logic.
579
+ - [ ] **Step 5: Run, verify PASS.**
580
+ - [ ] **Step 6: Commit** `refactor(config): shared parseAuthType incl. certificate/kerberos`.
581
+
582
+ ### Task 3.2: Read new env fields into `SapConfig`
583
+
584
+ **Files:**
585
+ - Modify: wherever `SapConfig` is assembled from env (`src/lib/config.ts`, and `src/lib/utils.ts` if it builds a parallel config).
586
+ - Test: extend the existing config-building test (or add one targeting the exported builder).
587
+
588
+ - [ ] **Step 1: Write failing test** — `SAP_AUTH_TYPE=certificate` + `SAP_CERT_PATH`/`SAP_CERT_KEY_PATH` env yields a config with those fields; `SAP_AUTH_TYPE=kerberos` + `SAP_KERBEROS_SPN` yields `kerberosSpn`.
589
+ - [ ] **Step 2: Run, verify FAIL.**
590
+ - [ ] **Step 3: Implement** — where the config object is built, add:
591
+
592
+ ```ts
593
+ certPath: process.env.SAP_CERT_PATH,
594
+ certKeyPath: process.env.SAP_CERT_KEY_PATH,
595
+ certPfxPath: process.env.SAP_CERT_PFX_PATH,
596
+ certPassphrase: process.env.SAP_CERT_PASSPHRASE,
597
+ kerberosSpn: process.env.SAP_KERBEROS_SPN,
598
+ kerberosService: process.env.SAP_KERBEROS_SERVICE,
599
+ ```
600
+
601
+ - [ ] **Step 4: Run, verify PASS.**
602
+ - [ ] **Step 5: Commit** `feat(config): read cert/kerberos env into SapConfig`.
603
+
604
+ ### Task 3.3: Confirm the connection-creation path (NOT the broker)
605
+
606
+ **Files:**
607
+ - Investigate, then minimal change: wherever the server calls `createAbapConnection(...)` for basic/RFC auth.
608
+
609
+ - [ ] **Step 1:** Locate where the server constructs an `AbapConnection` for non-broker auth (grep for `createAbapConnection` in `src/`). Confirm cert/kerberos `SapConfig` flows there unchanged. Certificate/kerberos must NOT be routed through `brokerFactory`/`IBrokerSessionConfig` (broker is OAuth-only).
610
+ - [ ] **Step 2:** If the path already passes the full `SapConfig` (with the new fields) to `createAbapConnection`, no code change is needed beyond Task 3.2 — add a test asserting that a `certificate`/`kerberos` config produces the right connection class via the server's creation path. If the server narrows authType anywhere (e.g. only handles basic/jwt before delegating), widen that branch.
611
+ - [ ] **Step 3:** Run, verify PASS. Commit (only if a change was needed) `feat(server): create certificate/kerberos connections via direct path`.
612
+
613
+ ### Task 3.4: Docs + help text
614
+
615
+ **Files:**
616
+ - Modify: `src/lib/utils.ts` (~1337 env help block), `README.md`, `CHANGELOG.md`
617
+
618
+ - [ ] **Step 1:** Add to help text:
619
+
620
+ ```
621
+ SAP_CERT_PATH / SAP_CERT_KEY_PATH Client cert + key (PEM) for certificate auth
622
+ SAP_CERT_PFX_PATH / SAP_CERT_PASSPHRASE PKCS#12 cert for certificate auth
623
+ SAP_KERBEROS_SPN SPN for kerberos auth (default HTTP@<host>)
624
+ SAP_AUTH_TYPE basic|jwt|saml|certificate|kerberos (default: basic)
625
+ ```
626
+
627
+ - [ ] **Step 2:** README: document cert/kerberos setup (on-prem HTTP only; kerberos prereq: `kinit`/keytab + optional `kerberos` npm). Update CHANGELOG.
628
+ - [ ] **Step 3: Commit** `docs: certificate + kerberos auth env vars and setup`.
629
+
630
+ ### Task 3.5: Integration smoke (live, gated)
631
+
632
+ **Files:**
633
+ - Create/extend: integration smoke behind env gates (see integration-test-env-gates skill).
634
+
635
+ - [ ] **Step 1:** Add `describeCertificate`/`describeKerberos` gates that run only when `SAP_CERT_PATH`/`SAP_KERBEROS_SPN` (+ a target system) are present. One read (e.g. read a class source) per auth type.
636
+ - [ ] **Step 2: Coordinate with the user** to run against a real on-prem system supporting the auth type (per CLAUDE.md — do not automate SAP env verification). Validate spec assumption A1 (single-leg) on kerberos here.
637
+ - [ ] **Step 3:** Save full log to `/tmp/integration-cert-kerberos.log` (no `tail` truncation). Commit any test additions.
638
+
639
+ ### Task 3.6: Release server (publish gate — user publishes)
640
+
641
+ - [ ] **Step 1:** Bump version + CHANGELOG, regenerate tool catalogs if affected (auth is not a tool — likely none). `npm run build` + tests green. **Do NOT publish.** PR, merge.
642
+ - [ ] **Step 2: HAND OFF — ask the user to publish the server package.**
643
+ - [ ] **Step 3:** Once the user confirms the full feature is published and verified, **delete the spec** `docs/superpowers/specs/2026-05-23-cert-kerberos-auth-design.md` and this plan (project lifecycle rule). History remains in git.
644
+
645
+ ---
646
+
647
+ ## Self-review notes
648
+
649
+ - **Spec coverage:** §3.1 → Phase 1 (done); §3.2 connection (cert+kerberos+spnego+factory) → Tasks 2.1–2.6; §3.3 (auth-providers/broker NOT touched) → respected (no tasks, by design); §3.4 server → Tasks 3.1–3.4; §4 config surface → 3.1/3.2/3.4; §5 security (no secret logging) → loader/signature avoid secrets; **NTLM reject** → folded into Task 2.5 note (add the guard when wiring the 401 path; test with mocked `www-authenticate: NTLM`); §6 test plan → per-task tests + 3.5; §7 release order → phase order; §8 A1/A4 → Tasks 2.5/2.3 notes.
650
+ - **No broker/providers tasks** — intentional (cert+kerberos bypass them).
651
+ - **Type consistency:** `ICertificateMaterialLoader.load(config)`, `getHttpsAgentOptions()`, `generateSpnegoToken(spn)`, `createAbapConnection(..., options?.certLoader)` are used consistently across tasks. Kerberos connection takes NO `tokenRefresher`.
652
+ - **Verify-in-code flags:** the `kerberos` npm token accessor (`client.response`, Task 2.4); the server's connection-creation path (Task 3.3); `AbstractAbapConnection` empty-header behavior for kerberos cookie phase (Task 2.5).