@mcp-abap-adt/core 6.9.0 → 6.9.2
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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [6.9.2] - 2026-05-24
|
|
6
|
+
|
|
7
|
+
### Documentation
|
|
8
|
+
- README: document the NTLM hard-reject behavior in the Kerberos auth section (carries the connection `1.9.1` change to the published package).
|
|
9
|
+
|
|
10
|
+
## [6.9.1] - 2026-05-24
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- Bump `@mcp-abap-adt/connection` to `^1.9.1`, which adds **NTLM hard-reject** for Kerberos auth: a Kerberos connection now fails with a clear error if the SAP system offers NTLM (instead of silently swallowing the 401). Only Kerberos/SPNEGO is accepted.
|
|
14
|
+
|
|
5
15
|
## [6.9.0] - 2026-05-24
|
|
6
16
|
|
|
7
17
|
### Added
|
package/README.md
CHANGED
|
@@ -336,6 +336,7 @@ SAP_AUTH_TYPE=kerberos
|
|
|
336
336
|
- The optional [`kerberos`](https://www.npmjs.com/package/kerberos) npm package must be installed (needs GSSAPI dev libs on Linux / build tools on Windows): `npm i kerberos`.
|
|
337
337
|
- No `SAP_USERNAME` / `SAP_PASSWORD` required — identity comes from the TGT.
|
|
338
338
|
- Both auth types bypass the auth-broker; use `.env` directly.
|
|
339
|
+
- **NTLM is hard-rejected:** if the SAP system offers NTLM instead of Kerberos/SPNEGO, the connection fails with a clear error rather than silently downgrading. Ensure the system accepts Kerberos (SPNEGO) for your user.
|
|
339
340
|
|
|
340
341
|
> **⚠️ Help wanted — not yet validated on a live system.** Certificate and Kerberos auth pass full unit coverage but have not been tested against a real SAP system. If you have on-prem **client-certificate** or **Kerberos/SPNEGO** SSO, please try it and [open an issue](https://github.com/fr0ster/mcp-abap-adt/issues) with results — especially whether Kerberos succeeds with a single-leg Negotiate token or your system needs mutual-auth continuation.
|
|
341
342
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mcp-abap-adt/core",
|
|
3
3
|
"mcpName": "io.github.fr0ster/mcp-abap-adt",
|
|
4
|
-
"version": "6.9.
|
|
4
|
+
"version": "6.9.2",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"exports": {
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
"@mcp-abap-adt/auth-broker": "^1.0.5",
|
|
141
141
|
"@mcp-abap-adt/auth-providers": "^1.0.5",
|
|
142
142
|
"@mcp-abap-adt/auth-stores": "^1.0.4",
|
|
143
|
-
"@mcp-abap-adt/connection": "^1.9.
|
|
143
|
+
"@mcp-abap-adt/connection": "^1.9.1",
|
|
144
144
|
"@mcp-abap-adt/header-validator": "^0.1.8",
|
|
145
145
|
"@mcp-abap-adt/interfaces": "^7.2.0",
|
|
146
146
|
"@mcp-abap-adt/logger": "^0.1.4",
|
|
@@ -1,652 +0,0 @@
|
|
|
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).
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
# Spec: Certificate (mTLS) + Kerberos auth across the ADT connection stack
|
|
2
|
-
|
|
3
|
-
> Status: design approved, ready for implementation planning.
|
|
4
|
-
> Lifecycle: this spec lives only in `mcp-abap-adt`. **Delete after implementation** — history stays in git.
|
|
5
|
-
> Source of idea: analysis of `sap-adt-mcp` (see `docs/superpowers/plans/innovations-from-sap-adt-mcp.md`, item A).
|
|
6
|
-
|
|
7
|
-
## 1. Goal & scope
|
|
8
|
-
|
|
9
|
-
Add two new authentication types to the ADT connection stack:
|
|
10
|
-
|
|
11
|
-
- **`certificate`** — client-certificate mutual TLS (mTLS), cross-platform, credential material loaded from **files** (PEM or PFX/PKCS#12).
|
|
12
|
-
- **`kerberos`** — SPNEGO/Negotiate SSO, cross-platform via the `kerberos` npm package (GSSAPI on Linux/macOS, SSPI on Windows), as an **optional dependency**.
|
|
13
|
-
|
|
14
|
-
**In scope:** on-prem ABAP over **HTTP** (`connectionType: 'http'`). Both new types are invalid for `connectionType: 'rfc'` and for BTP cloud (which stays `jwt`).
|
|
15
|
-
|
|
16
|
-
**Out of scope (explicitly NOT building):**
|
|
17
|
-
- Windows Certificate Store / PowerShell-SChannel bridge (the `sap-adt-mcp` approach). Cross-platform file-based only.
|
|
18
|
-
- NTLM. We hard-reject NTLM tokens if a server offers only NTLM (see Risks).
|
|
19
|
-
- Kerberos mutual-auth multi-leg continuation (see Assumptions).
|
|
20
|
-
- RFC-transport variants of these auth types.
|
|
21
|
-
|
|
22
|
-
## 2. Architecture decision (the core call)
|
|
23
|
-
|
|
24
|
-
**Both new auth types bypass `@mcp-abap-adt/auth-broker` and `@mcp-abap-adt/auth-providers` entirely.** The broker's role is narrow: OAuth-style authorization with browser-callback interception. Certificate and Kerberos are non-interactive and transport/connection-level — no browser callback, no broker token lifecycle. They live in the **connection** package (+ types in `interfaces`, + env wiring in the server).
|
|
25
|
-
|
|
26
|
-
| Type | Pattern | Why |
|
|
27
|
-
|------|---------|-----|
|
|
28
|
-
| **Certificate** | Connection subclass + `httpsAgent` options, with an injected `ICertificateMaterialLoader` for testability | The credential is `cert`+private-key used in the **TLS handshake via `httpsAgent`** — transport-layer, below `getAuthHeaders()`. No header/cookie to "provide". |
|
|
29
|
-
| **Kerberos** | Connection subclass that generates the SPNEGO token **locally** (lazy/optional `kerberos` npm wrapper inside the connection package), sends `Authorization: Negotiate <token>` on the first request, then reuses the SAP session cookie | The Negotiate blob goes into a header, but generation is local GSSAPI (no callback, no refresh lifecycle). No broker, no `ITokenRefresher`, no `BaseTokenProvider`. |
|
|
30
|
-
|
|
31
|
-
`@mcp-abap-adt/connection` depends only on `interfaces` + `sap-rfc-lite` (not on `auth-providers`), so the SPNEGO wrapper lives in `connection`, keeping that dependency boundary intact.
|
|
32
|
-
|
|
33
|
-
### Kerberos handshake = single-leg, cookie reuse
|
|
34
|
-
|
|
35
|
-
`AbstractAbapConnection` already owns CSRF fetch + cookie capture/reuse (`fetchCsrfToken`, cookie merge). `KerberosAbapConnection` generates one AP-REQ for the SPN, sends `Authorization: Negotiate <token>` on the first request, captures the SAP session cookie (`SAP_SESSIONID`/`MYSAPSSO2`), and reuses the cookie thereafter. Single-leg only (no mutual-auth continuation in v1 — see Assumptions).
|
|
36
|
-
|
|
37
|
-
## 3. Per-package changes
|
|
38
|
-
|
|
39
|
-
**Three repos** (auth-broker and auth-providers are NOT touched). Listed in dependency (release) order — see §7.
|
|
40
|
-
|
|
41
|
-
### 3.1 `@mcp-abap-adt/interfaces` (`~/prj/mcp-abap-adt-interfaces`)
|
|
42
|
-
|
|
43
|
-
- `src/sap/SapAuthType.ts`: extend union →
|
|
44
|
-
`export type SapAuthType = 'basic' | 'jwt' | 'saml' | 'certificate' | 'kerberos';`
|
|
45
|
-
- `src/sap/ISapConfig.ts`: add optional fields:
|
|
46
|
-
- Certificate: `certPath?`, `certKeyPath?`, `certPfxPath?`, `certPassphrase?`. (PEM = certPath+certKeyPath; PFX = certPfxPath. Mutually exclusive — validated in connector.)
|
|
47
|
-
- Kerberos: `kerberosSpn?` (e.g. `HTTP@host.domain`), `kerberosService?` (default `HTTP`).
|
|
48
|
-
- `src/token/ITokenResult.ts`: no shape change needed — kerberos uses existing `tokenType: 'opaque'`. (Optional: add `'kerberos'` to the `tokenType` union for clarity; not required.)
|
|
49
|
-
- New interface `src/auth/ICertificateMaterialLoader.ts`:
|
|
50
|
-
```ts
|
|
51
|
-
export interface ICertificateMaterial { cert?: Buffer|string; key?: Buffer|string; pfx?: Buffer; passphrase?: string; }
|
|
52
|
-
export interface ICertificateMaterialLoader { load(config: ISapConfig): Promise<ICertificateMaterial>; }
|
|
53
|
-
```
|
|
54
|
-
- Export new symbols from `src/index.ts`.
|
|
55
|
-
|
|
56
|
-
### 3.2 `@mcp-abap-adt/connection` (`~/prj/mcp-abap-connection`) — the connector, heaviest changes
|
|
57
|
-
|
|
58
|
-
- `src/connection/AbstractAbapConnection.ts`:
|
|
59
|
-
- In `getAxiosInstance()` (currently builds `new Agent({ rejectUnauthorized })`), add a protected hook:
|
|
60
|
-
`protected getHttpsAgentOptions(): https.AgentOptions { return {}; }`
|
|
61
|
-
and merge it into the Agent options. **Important:** the Agent is currently cached on first build; certificate material must be available before the first request (it is — loaded in the cert connection's `connect()`/constructor). Keep `rejectUnauthorized` behavior intact.
|
|
62
|
-
- New `src/connection/CertificateAbapConnection.ts` (extends `AbstractAbapConnection`):
|
|
63
|
-
- `validateConfig`: requires `connectionType !== 'rfc'`; requires either (`certPath`+`certKeyPath`) or `certPfxPath`; rejects having both PEM and PFX.
|
|
64
|
-
- Constructor takes an injected `ICertificateMaterialLoader` (default: a file-based impl). `connect()` loads material, then proceeds with normal CSRF fetch.
|
|
65
|
-
- `getHttpsAgentOptions()` returns `{ cert, key, pfx, passphrase }`.
|
|
66
|
-
- `buildAuthorizationHeader()` returns `''` (no Authorization header; mTLS identifies the user).
|
|
67
|
-
- New `src/connection/KerberosAbapConnection.ts` (extends `AbstractAbapConnection`):
|
|
68
|
-
- **Self-contained — no broker, no `ITokenRefresher`, no provider.** Generates the SPNEGO token locally via the connection-local wrapper (below), using the resolved SPN.
|
|
69
|
-
- `connect()` generates the Negotiate token, sends it on the first request, captures the SAP session cookie via the existing cookie machinery; subsequent requests reuse the cookie.
|
|
70
|
-
- `buildAuthorizationHeader()` → `Negotiate ${token}` while no session cookie yet; `''` once a cookie exists.
|
|
71
|
-
- `validateConfig`: requires `connectionType !== 'rfc'`; requires resolvable SPN (`kerberosSpn` or derive `HTTP@<host>` from URL).
|
|
72
|
-
- New `src/auth/FileCertificateMaterialLoader.ts`: reads PEM/PFX from disk → `ICertificateMaterial`. Pure I/O, mockable.
|
|
73
|
-
- New `src/auth/kerberosSpnego.ts`: thin wrapper over the optional `kerberos` npm package (`initializeClient` / `step`), **lazy `import()`**; declared in this package's `optionalDependencies`. The single native, mockable seam. (Lives here, not in auth-providers, because `connection` does not depend on `auth-providers`.)
|
|
74
|
-
- `src/connection/connectionFactory.ts`: add cases:
|
|
75
|
-
```ts
|
|
76
|
-
case 'certificate': return new CertificateAbapConnection(config, logger, sessionId, options?.certLoader);
|
|
77
|
-
case 'kerberos': return new KerberosAbapConnection(config, logger, sessionId);
|
|
78
|
-
```
|
|
79
|
-
Keep the existing `connectionType === 'rfc'` early-return guard (rfc wins) — but add validation so cert/kerberos + rfc throws a clear error rather than silently doing RFC.
|
|
80
|
-
- `src/config/sapConfig.ts`: extend `sapConfigSignature()` to include cert paths / SPN (so connection is recreated when these change). Never log key/passphrase contents.
|
|
81
|
-
|
|
82
|
-
### 3.3 NOT touched: `auth-providers` and `auth-broker`
|
|
83
|
-
|
|
84
|
-
Certificate and Kerberos bypass both. The broker is only for OAuth-style authorization + browser-callback interception; cert/kerberos are non-interactive and connection-level. **Do not add a `KerberosProvider` to auth-providers, do not modify `AuthBroker`, do not widen the broker's `IConnectionConfig.authType`** — that union is the broker's surface and stays `'basic' | 'jwt' | 'saml'`.
|
|
85
|
-
|
|
86
|
-
### 3.4 `mcp-abap-adt` (server, this repo)
|
|
87
|
-
|
|
88
|
-
- `src/lib/config.ts` (lines ~59-75) and `src/lib/utils.ts` (duplicate parser ~1892-1900): extend `SAP_AUTH_TYPE` acceptance to include `'certificate'` and `'kerberos'`. **De-dup opportunity:** these two parsers are copies — extract one shared `parseAuthType()` (recommended while here).
|
|
89
|
-
- Read new env into the assembled `SapConfig`: `SAP_CERT_PATH`, `SAP_CERT_KEY_PATH`, `SAP_CERT_PFX_PATH`, `SAP_CERT_PASSPHRASE`, `SAP_KERBEROS_SPN`, `SAP_KERBEROS_SERVICE`. Document in the help text (~line 1337).
|
|
90
|
-
- **Connection creation path:** find where the server creates an `AbapConnection` for non-broker auth (basic/RFC today) — cert/kerberos follow that same direct `createAbapConnection(...)` path, NOT the broker/`brokerFactory` path. Verify in code during Phase 3; do NOT route cert/kerberos through `brokerFactory`/`IBrokerSessionConfig`.
|
|
91
|
-
- Tool `available_in`: cert/kerberos are connection concerns, not tool capabilities — no tool-level change; but verify nothing assumes `authType ∈ {basic,jwt,saml}`.
|
|
92
|
-
|
|
93
|
-
## 4. Configuration surface (summary)
|
|
94
|
-
|
|
95
|
-
`.env` (server-driven, on-prem HTTP):
|
|
96
|
-
```
|
|
97
|
-
SAP_AUTH_TYPE=certificate
|
|
98
|
-
SAP_CERT_PATH=/path/client.crt # PEM
|
|
99
|
-
SAP_CERT_KEY_PATH=/path/client.key # PEM
|
|
100
|
-
# or:
|
|
101
|
-
SAP_CERT_PFX_PATH=/path/client.pfx # PKCS#12
|
|
102
|
-
SAP_CERT_PASSPHRASE=... # optional
|
|
103
|
-
```
|
|
104
|
-
```
|
|
105
|
-
SAP_AUTH_TYPE=kerberos
|
|
106
|
-
SAP_KERBEROS_SPN=HTTP@sap-host.corp # optional; derived from SAP_URL if absent
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
## 5. Security rules
|
|
110
|
-
|
|
111
|
-
- Never log private-key bytes, PFX bytes, passphrase, or Negotiate tokens. Safe to log: cert subject/CN, cert path (not contents), SPN, `tokenType`.
|
|
112
|
-
- `sapConfigSignature()` must not embed secret material — use presence flags / hashes, mirroring how it previews tokens today.
|
|
113
|
-
- Reject NTLM: if the server's `WWW-Authenticate` offers only `NTLM` (token begins `TlRMTVNTUAA...`), fail with a clear error rather than negotiating.
|
|
114
|
-
|
|
115
|
-
## 6. Test plan
|
|
116
|
-
|
|
117
|
-
Per repo, unit-first (mock the native/IO seams):
|
|
118
|
-
|
|
119
|
-
- **interfaces**: type-level only (compile).
|
|
120
|
-
- **connection**:
|
|
121
|
-
- `CertificateAbapConnection`: inject a fake `ICertificateMaterialLoader`; assert `getHttpsAgentOptions()` carries cert/key/pfx; assert no Authorization header; assert PEM-vs-PFX validation and rfc-conflict errors.
|
|
122
|
-
- `KerberosAbapConnection`: mock the connection-local `kerberosSpnego` wrapper; assert `Authorization: Negotiate <token>` on first request and `''` after a cookie exists; assert cookie capture+reuse; assert rfc-conflict error. No real KDC.
|
|
123
|
-
- `connectionFactory`: new cases route correctly; cert/kerberos + rfc throws.
|
|
124
|
-
- **server**: `SAP_AUTH_TYPE=certificate|kerberos` parsed (shared `parseAuthType`); cert/kerberos env fields land in the assembled `SapConfig`; connection created via the direct `createAbapConnection` path (not broker).
|
|
125
|
-
- **Integration (live SAP, gated):** one cert smoke + one kerberos smoke against an on-prem system that supports them. Follow CLAUDE.md soft-mode strategy; do not block CI on secrets (see integration-test-env-gates approach).
|
|
126
|
-
|
|
127
|
-
## 7. Release order (cross-package)
|
|
128
|
-
|
|
129
|
-
Bottom-up. **The user publishes each package — the agent never runs `npm publish`.** Consumers import published npm versions (not local links), so each step is hard-gated: implement → commit → PR/merge → user publishes → bump consumer to the confirmed version → next step (follow the cross-package-fix-cycle discipline):
|
|
130
|
-
|
|
131
|
-
1. `interfaces` — union + fields + `ICertificateMaterialLoader`. User publishes, bump everywhere.
|
|
132
|
-
2. `connection` — connection classes + agent hook + cert loader + `kerberosSpnego` (optionalDep) + factory + signature. User publishes.
|
|
133
|
-
3. `mcp-abap-adt` (server) — `parseAuthType` + env→`SapConfig` + direct `createAbapConnection` wiring + docs. Bump deps to the published versions.
|
|
134
|
-
|
|
135
|
-
(`auth-providers` and `auth-broker` are not in the chain — not touched.) Each step gets its own worktree in its repo (per project workflow: worktrees on all changed code repos; spec stays only here).
|
|
136
|
-
|
|
137
|
-
## 8. Risks & assumptions
|
|
138
|
-
|
|
139
|
-
- **A1 — single-leg Negotiate.** Assumes SAP accepts a one-shot AP-REQ then issues a session cookie (typical for Kerberos). If a system demands mutual-auth continuation (`WWW-Authenticate: Negotiate <challenge>` round-trips), the single-token approach does not cover it; would need a continuation hook. **Accepted for v1** (user-confirmed); validate on a live system before claiming kerberos done.
|
|
140
|
-
- **A2 — native build.** `kerberos` npm compiles native bindings (needs GSSAPI dev libs on Linux / build tools on Windows). Mitigated by `optionalDependencies` + lazy import so non-kerberos installs never touch it.
|
|
141
|
-
- **A3 — TGT availability.** Cross-platform kerberos needs a valid TGT in the OS credential cache (`kinit`) or a keytab. Document prerequisite; not auto-provisioned.
|
|
142
|
-
- **A4 — Agent caching.** `getAxiosInstance()` caches the Agent; ensure cert material is loaded before the first request (it is, in `connect()`), else the cached Agent lacks the client cert.
|
|
143
|
-
- **A5 — duplicate auth parser.** `config.ts` and `utils.ts` both parse `SAP_AUTH_TYPE`; both must change. Optional de-dup flagged in §3.5.
|
|
144
|
-
|
|
145
|
-
## 9. Decisions (resolved)
|
|
146
|
-
|
|
147
|
-
1. **PEM and PFX both supported** in v1. (user-confirmed)
|
|
148
|
-
2. **Single-leg Negotiate accepted** for v1; no mutual-auth continuation. (user-confirmed)
|
|
149
|
-
3. **cert and kerberos both bypass the broker and auth-providers** — connection-layer only. (user-confirmed)
|