Skip to content

Commit 6f7dc55

Browse files
committed
feat(spec)!: connector instance auth refuses an inline secret on every kind; the metadata read redacts connector credential positions
The four static instance-auth kinds (none, bearer, api-key, basic) are strict shapes now, as the ADR-0143 kinds already were: an undeclared key is refused at parse, and an inline secret meets the credentialRef prescription. A built-in connector redactor withholds undeclared auth keys, the non-type keys of a runtime authentication block, and the provider header and environment maps on every metadata read exit. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3718424 commit 6f7dc55

7 files changed

Lines changed: 619 additions & 10 deletions
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0097 §3 / ADR-0143 D3 — the four static kinds of declarative connector
4+
// instance auth (`none`, `bearer`, `api-key`, `basic`) refuse an undeclared
5+
// key at the publish door (`DeclarativeConnectorEntrySchema`, which both
6+
// authoring doors parse), and an inline secret meets the `credentialRef`
7+
// prescription — the refusal the three ADR-0143 kinds already gave. Every
8+
// refusal is pinned by its issue `code`, `path` and `keys`; every kind has a
9+
// CONTROL accept, so a refusal that fired on everything could not pass.
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { DeclarativeConnectorEntrySchema } from './connector.zod';
13+
import { ObjectStackDefinitionSchema } from '../stack.zod';
14+
15+
/** An obviously-fake placeholder: the refusal must never echo it. */
16+
const PLACEHOLDER = 'TEST_PLACEHOLDER_NOT_A_SECRET';
17+
18+
const entry = (auth: unknown) => ({
19+
name: 'vendor',
20+
label: 'Vendor',
21+
type: 'api',
22+
provider: 'rest',
23+
providerConfig: { baseUrl: 'https://vendor.example.com' },
24+
auth,
25+
});
26+
27+
const DECLARED = {
28+
none: { type: 'none' },
29+
bearer: { type: 'bearer', credentialRef: 'VENDOR_TOKEN' },
30+
'api-key': { type: 'api-key', credentialRef: 'VENDOR_KEY', headerName: 'X-Vendor-Key' },
31+
'api-key (query)': { type: 'api-key', credentialRef: 'VENDOR_KEY', paramName: 'key' },
32+
basic: { type: 'basic', username: 'svc_objectstack', credentialRef: 'VENDOR_PASSWORD' },
33+
} as const;
34+
35+
/** The one issue of a refused parse, as `[code, path, keys]`, and its message. */
36+
function refusal(input: unknown): { located: [string, string, unknown]; message: string } {
37+
const result = DeclarativeConnectorEntrySchema.safeParse(input);
38+
expect(result.success, 'the entry must be refused').toBe(false);
39+
expect(result.error!.issues).toHaveLength(1);
40+
const issue = result.error!.issues[0]!;
41+
return {
42+
located: [issue.code, issue.path.join('.'), (issue as { keys?: unknown }).keys],
43+
message: issue.message,
44+
};
45+
}
46+
47+
describe('the four static instance-auth kinds refuse an undeclared key (ADR-0143 D3)', () => {
48+
it.each(Object.entries(DECLARED))('CONTROL: %s with only its declared keys parses and comes back unchanged', (_kind, auth) => {
49+
expect(DeclarativeConnectorEntrySchema.parse(entry(auth)).auth).toEqual(auth);
50+
});
51+
52+
it.each([
53+
['bearer', { ...DECLARED.bearer, token: PLACEHOLDER }, 'token'],
54+
['bearer', { ...DECLARED.bearer, accessToken: PLACEHOLDER }, 'accessToken'],
55+
['api-key', { ...DECLARED['api-key'], key: PLACEHOLDER }, 'key'],
56+
['api-key', { ...DECLARED['api-key'], apiKey: PLACEHOLDER }, 'apiKey'],
57+
['basic', { ...DECLARED.basic, password: PLACEHOLDER }, 'password'],
58+
] as const)('%s refuses an inline secret (`%s`) by name at `auth`, pointing at credentialRef', (kind, auth, key) => {
59+
const { located, message } = refusal(entry(auth));
60+
expect(located).toEqual(['unrecognized_keys', 'auth', [key]]);
61+
expect(message).toContain(`Not authorable on \`auth: { type: '${kind}' }\``);
62+
expect(message).toContain('credentialRef');
63+
expect(message).not.toContain(PLACEHOLDER);
64+
});
65+
66+
it('none refuses a credential, and sends the author to a kind that takes one', () => {
67+
const { located, message } = refusal(entry({ type: 'none', token: PLACEHOLDER }));
68+
expect(located).toEqual(['unrecognized_keys', 'auth', ['token']]);
69+
expect(message).toContain('takes no credential');
70+
expect(message).not.toContain(PLACEHOLDER);
71+
});
72+
73+
it('none refuses a credentialRef too: it names an upstream that takes no credential', () => {
74+
expect(refusal(entry({ type: 'none', credentialRef: 'VENDOR_TOKEN' })).located)
75+
.toEqual(['unrecognized_keys', 'auth', ['credentialRef']]);
76+
});
77+
78+
it('a non-secret undeclared key is refused as well — the shape is closed, not a denylist', () => {
79+
const { located, message } = refusal(entry({ ...DECLARED.bearer, headerName: 'X-Vendor-Key' }));
80+
expect(located).toEqual(['unrecognized_keys', 'auth', ['headerName']]);
81+
expect(message).toContain('Unrecognized key(s) on this `bearer` connector auth');
82+
});
83+
84+
it('the refusal runs on the authored stack collection as well (defineStack `connectors:`)', () => {
85+
const result = ObjectStackDefinitionSchema.safeParse({
86+
manifest: { id: 'com.example.vendor', name: 'vendor', version: '1.0.0', type: 'app' as const },
87+
connectors: [entry({ ...DECLARED.bearer, token: PLACEHOLDER })],
88+
});
89+
expect(result.success).toBe(false);
90+
const paths = result.error!.issues.map((i) => `${i.code} @ ${i.path.join('.')}`);
91+
expect(paths).toContain('unrecognized_keys @ connectors.0.auth');
92+
});
93+
});
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The built-in `connector` read-path redactor
4+
// (`connector-credential-redaction.ts`): which positions it withholds, what it
5+
// leaves byte for byte, and the fail-closed census that keeps a secret from
6+
// ever becoming a DECLARED instance-auth key unnoticed.
7+
//
8+
// Every value below is an obviously-fake placeholder; every assertion on one is
9+
// its absence from the served item.
10+
11+
import { describe, it, expect } from 'vitest';
12+
import {
13+
CONNECTOR_CREDENTIAL_MAP_PATHS,
14+
declaredConnectorAuthKeys,
15+
redactConnectorItem,
16+
} from './connector-credential-redaction';
17+
18+
const P = (n: number) => `TEST_PLACEHOLDER_${n}`;
19+
20+
const base = {
21+
name: 'vendor',
22+
label: 'Vendor',
23+
type: 'api',
24+
description: 'A vendor API.',
25+
provider: 'rest',
26+
enabled: true,
27+
_diagnostics: { valid: false, issues: [{ path: 'auth', message: 'Unrecognized key(s)' }] },
28+
};
29+
30+
const holds = (v: unknown, ...needles: string[]) => needles.some((n) => JSON.stringify(v).includes(n));
31+
32+
describe('the connector redactor — instance `auth`', () => {
33+
it('withholds an undeclared key on a static kind and serves the declared ones as stored', () => {
34+
const item = { ...base, auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', token: P(1) } };
35+
const { item: served, redactedKeys } = redactConnectorItem(item);
36+
expect(redactedKeys).toEqual(['auth.token']);
37+
expect(served.auth).toEqual({ type: 'bearer', credentialRef: 'VENDOR_TOKEN' });
38+
expect(holds(served, P(1))).toBe(false);
39+
});
40+
41+
it.each([
42+
['api-key', { type: 'api-key', credentialRef: 'K', headerName: 'X-Key', key: P(2) }, ['auth.key']],
43+
['basic', { type: 'basic', username: 'svc', credentialRef: 'PW', password: P(3) }, ['auth.password']],
44+
['none', { type: 'none', token: P(4), key: P(5) }, ['auth.key', 'auth.token']],
45+
] as const)('%s: withholds exactly the undeclared keys', (_kind, auth, expected) => {
46+
const { item: served, redactedKeys } = redactConnectorItem({ ...base, auth });
47+
expect(redactedKeys).toEqual(expected);
48+
expect(holds(served, P(2), P(3), P(4), P(5))).toBe(false);
49+
});
50+
51+
it('a grant kind keeps every key its grant declares and withholds the secret beside them', () => {
52+
const auth = {
53+
type: 'oauth2', grant: 'authorization_code', authorizationUrl: 'https://v.example/authorize',
54+
tokenUrl: 'https://v.example/token', clientId: 'client-1', credentialRef: 'V_CLIENT_SECRET',
55+
scopes: ['read'], clientSecret: P(6), refreshToken: P(7),
56+
};
57+
const { item: served, redactedKeys } = redactConnectorItem({ ...base, auth });
58+
expect(redactedKeys).toEqual(['auth.clientSecret', 'auth.refreshToken']);
59+
const { clientSecret: _s, refreshToken: _r, ...declared } = auth;
60+
expect(served.auth).toEqual(declared);
61+
});
62+
63+
it('a signed scheme keeps its own declared reference keys and withholds key material', () => {
64+
const auth = {
65+
type: 'signed', scheme: 'alipay-v3', keyId: 'app-1', credentialRef: 'ALIPAY_PRIVATE_KEY',
66+
vendorPublicKeyRef: 'ALIPAY_PUBLIC_KEY', appPrivateKey: P(8),
67+
};
68+
const { item: served, redactedKeys } = redactConnectorItem({ ...base, auth });
69+
expect(redactedKeys).toEqual(['auth.appPrivateKey']);
70+
expect((served.auth as Record<string, unknown>).vendorPublicKeyRef).toBe('ALIPAY_PUBLIC_KEY');
71+
});
72+
73+
it('a `type` that names no kind leaves `type` alone declared (fail closed)', () => {
74+
const { item: served, redactedKeys } = redactConnectorItem({ ...base, auth: { type: 'digest', credentialRef: 'X', secret: P(9) } });
75+
expect(redactedKeys).toEqual(['auth.credentialRef', 'auth.secret']);
76+
expect(served.auth).toEqual({ type: 'digest' });
77+
});
78+
79+
it('an unknown grant keeps the keys its kind\'s grants declare, never the secret', () => {
80+
const keys = declaredConnectorAuthKeys({ type: 'oauth2', grant: 'password' });
81+
expect(keys.has('tokenUrl')).toBe(true);
82+
expect(keys.has('authorizationUrl')).toBe(true);
83+
expect(keys.has('clientSecret')).toBe(false);
84+
});
85+
});
86+
87+
describe('the connector redactor — runtime `authentication` and provider maps', () => {
88+
it('withholds every `authentication` key but `type`', () => {
89+
const { item: served, redactedKeys } = redactConnectorItem({
90+
...base, authentication: { type: 'api-key', key: P(10), headerName: 'X-Key' },
91+
});
92+
expect(redactedKeys).toEqual(['authentication.headerName', 'authentication.key']);
93+
expect(served.authentication).toEqual({ type: 'api-key' });
94+
});
95+
96+
it('`authentication: { type: \'none\' }` holds nothing to withhold', () => {
97+
const item = { ...base, authentication: { type: 'none' } };
98+
expect(redactConnectorItem(item)).toEqual({ item, redactedKeys: [] });
99+
});
100+
101+
it('withholds each header and environment map WHOLE, and serves the rest of providerConfig as stored', () => {
102+
const item = {
103+
...base,
104+
providerConfig: {
105+
baseUrl: 'https://vendor.example.com',
106+
headers: { Authorization: `Bearer ${P(11)}` },
107+
defaultHeaders: { Accept: 'application/json', 'X-Api-Key': P(12) },
108+
transport: { kind: 'http', url: 'https://mcp.example.com', headers: { Cookie: P(13) }, env: { VENDOR_TOKEN: P(14) } },
109+
},
110+
};
111+
const { item: served, redactedKeys } = redactConnectorItem(item);
112+
expect(redactedKeys).toEqual([
113+
'providerConfig.defaultHeaders',
114+
'providerConfig.headers',
115+
'providerConfig.transport.env',
116+
'providerConfig.transport.headers',
117+
]);
118+
expect(served.providerConfig).toEqual({
119+
baseUrl: 'https://vendor.example.com',
120+
transport: { kind: 'http', url: 'https://mcp.example.com' },
121+
});
122+
expect(holds(served, P(11), P(12), P(13), P(14))).toBe(false);
123+
});
124+
125+
it('serves a cleared map and a null as written, and reports neither', () => {
126+
const item = { ...base, providerConfig: { baseUrl: 'https://v.example', headers: {}, defaultHeaders: null } };
127+
expect(redactConnectorItem(item)).toEqual({ item, redactedKeys: [] });
128+
});
129+
130+
it('withholds a non-map value at a map position too (fail closed)', () => {
131+
const { redactedKeys } = redactConnectorItem({ ...base, providerConfig: { headers: `Authorization: Bearer ${P(15)}` } });
132+
expect(redactedKeys).toEqual(['providerConfig.headers']);
133+
});
134+
135+
it('the four map positions are the ones the providers send upstream', () => {
136+
expect(CONNECTOR_CREDENTIAL_MAP_PATHS.map((p) => p.join('.'))).toEqual([
137+
'providerConfig.headers',
138+
'providerConfig.defaultHeaders',
139+
'providerConfig.transport.headers',
140+
'providerConfig.transport.env',
141+
]);
142+
});
143+
});
144+
145+
describe('the connector redactor — purity and the untouched remainder', () => {
146+
it('never mutates its input, and leaves every other key (`_diagnostics` included) byte for byte', () => {
147+
const item = {
148+
...base,
149+
auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN', token: P(16) },
150+
providerConfig: { baseUrl: 'https://v.example', defaultHeaders: { Authorization: P(17) } },
151+
retryConfig: { maxAttempts: 3 },
152+
};
153+
const before = JSON.stringify(item);
154+
const { item: served } = redactConnectorItem(item);
155+
expect(JSON.stringify(item)).toBe(before);
156+
for (const key of ['name', 'label', 'type', 'description', 'provider', 'enabled', '_diagnostics', 'retryConfig']) {
157+
expect(served[key], key).toEqual((item as Record<string, unknown>)[key]);
158+
}
159+
});
160+
161+
it('an item with nothing to withhold is returned by reference with []', () => {
162+
const item = { ...base, auth: { type: 'bearer', credentialRef: 'VENDOR_TOKEN' }, providerConfig: { baseUrl: 'https://v.example' } };
163+
const result = redactConnectorItem(item);
164+
expect(result.item).toBe(item);
165+
expect(result.redactedKeys).toEqual([]);
166+
});
167+
});
168+
169+
/**
170+
* Fail-closed census: the redactor serves every key an instance-auth kind
171+
* DECLARES, so a secret that became a declared key would be served. Every key
172+
* any kind declares is listed here; a key added to the union reds this test
173+
* until it is confirmed to be a reference or not a secret, and listed.
174+
*/
175+
const DECLARED_NOT_A_SECRET = [
176+
'appId', 'authorizationUrl', 'clientId', 'credentialRef', 'grant', 'headerName', 'keyId',
177+
'paramName', 'resource', 'scheme', 'scopes', 'tokenUrl', 'type', 'username', 'vendor',
178+
'vendorPublicKeyRef',
179+
];
180+
181+
describe('fail-closed census of the declared instance-auth keys', () => {
182+
it('every key any kind declares is a reference or not a secret, and is listed', () => {
183+
const all = new Set<string>();
184+
for (const type of ['none', 'bearer', 'api-key', 'basic', 'oauth2', 'app-credential', 'signed']) {
185+
for (const key of declaredConnectorAuthKeys({ type })) all.add(key);
186+
}
187+
expect([...all].sort()).toEqual(DECLARED_NOT_A_SECRET);
188+
});
189+
});

0 commit comments

Comments
 (0)