@openwop/openwop-conformance 1.0.0 → 1.1.1
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 +17 -0
- package/README.md +31 -6
- package/api/grpc/openwop.proto +251 -0
- package/api/openapi.yaml +109 -3
- package/coverage.md +48 -9
- package/fixtures/conformance-configurable-schema.json +39 -0
- package/fixtures/conformance-subworkflow-parent.json +1 -1
- package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
- package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
- package/fixtures.md +21 -0
- package/package.json +3 -1
- package/schemas/README.md +4 -0
- package/schemas/audit-verify-result.schema.json +90 -0
- package/schemas/capabilities.schema.json +342 -1
- package/schemas/node-pack-manifest.schema.json +4 -4
- package/schemas/pack-lockfile.schema.json +92 -0
- package/schemas/registry-version-manifest.schema.json +145 -0
- package/schemas/run-event-payloads.schema.json +20 -4
- package/schemas/run-event.schema.json +2 -1
- package/schemas/security-advisory.schema.json +109 -0
- package/src/lib/a2a-fake-peer.ts +143 -56
- package/src/lib/behavior-gate.ts +107 -0
- package/src/lib/env.ts +37 -0
- package/src/lib/grpc-framing.test.ts +96 -0
- package/src/lib/grpc-framing.ts +76 -0
- package/src/lib/oidc-issuer.test.ts +328 -0
- package/src/lib/oidc-issuer.ts +241 -0
- package/src/lib/otel-collector-grpc.test.ts +191 -0
- package/src/lib/otel-collector.test.ts +303 -0
- package/src/lib/otel-collector.ts +318 -14
- package/src/lib/otlp-protobuf.test.ts +461 -0
- package/src/lib/otlp-protobuf.ts +529 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
- package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
- package/src/scenarios/agentMessageReducer.test.ts +1 -0
- package/src/scenarios/agentMetadata.test.ts +1 -0
- package/src/scenarios/agentPackExport.test.ts +1 -0
- package/src/scenarios/agentPackInstall.test.ts +1 -0
- package/src/scenarios/agentPackProvenance.test.ts +1 -0
- package/src/scenarios/audit-log-integrity.test.ts +3 -6
- package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
- package/src/scenarios/auth-mtls.test.ts +274 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
- package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
- package/src/scenarios/bulk-cancel.test.ts +111 -0
- package/src/scenarios/configurable-schema.test.ts +48 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
- package/src/scenarios/conversationLifecycle.test.ts +1 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
- package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
- package/src/scenarios/discovery.test.ts +183 -0
- package/src/scenarios/http-client-ssrf.test.ts +71 -0
- package/src/scenarios/idempotency.test.ts +6 -0
- package/src/scenarios/idempotencyRetry.test.ts +3 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +205 -34
- package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
- package/src/scenarios/memory-compaction-event-emitted.test.ts +121 -0
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +116 -0
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +127 -0
- package/src/scenarios/metric-emission.test.ts +113 -0
- package/src/scenarios/multi-region-idempotency.test.ts +39 -4
- package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
- package/src/scenarios/orchestratorDispatch.test.ts +1 -0
- package/src/scenarios/orchestratorTermination.test.ts +1 -0
- package/src/scenarios/otel-emission-grpc.test.ts +98 -0
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +139 -0
- package/src/scenarios/pause-resume.test.ts +119 -0
- package/src/scenarios/production-backpressure.test.ts +342 -0
- package/src/scenarios/production-retention-expiry.test.ts +164 -0
- package/src/scenarios/registry-public.test.ts +222 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
- package/src/scenarios/replay-retention-expiry.test.ts +178 -0
- package/src/scenarios/restart-during-run.test.ts +177 -0
- package/src/scenarios/spec-corpus-validity.test.ts +59 -26
- package/src/scenarios/staleClaim.test.ts +3 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
- package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
- package/src/scenarios/webhook-negative.test.ts +90 -0
- package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
- package/src/setup.ts +25 -1
- package/vitest.config.ts +5 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0010 §F: openwop-auth-mtls profile (opt-in).
|
|
3
|
+
*
|
|
4
|
+
* Verifies that hosts claiming the mTLS profile satisfy
|
|
5
|
+
* `spec/v1/auth-profiles.md` §`openwop-auth-mtls`:
|
|
6
|
+
*
|
|
7
|
+
* 1. `capabilities.auth.profiles[]` includes `openwop-auth-mtls`
|
|
8
|
+
* and `mtls.supported === true`.
|
|
9
|
+
* 2. `mtls.required` (when present) is a boolean; `subjectMapping`
|
|
10
|
+
* (when present) is one of the canonical enum values
|
|
11
|
+
* (cn / san-dns / san-uri).
|
|
12
|
+
* 3. Request with valid client cert + valid bearer → 201 on
|
|
13
|
+
* `POST /v1/runs`.
|
|
14
|
+
* 4. When `mtls.required === true`, a bearer-only request (no
|
|
15
|
+
* client cert) MUST fail with non-2xx OR a transport-layer TLS
|
|
16
|
+
* failure (`auth-profiles.md` lets hosts choose either).
|
|
17
|
+
*
|
|
18
|
+
* Capability shape runs unconditionally when the profile is advertised.
|
|
19
|
+
* Behavior portion is opt-in via `OPENWOP_TEST_MTLS=1` and requires
|
|
20
|
+
* operator-supplied cert paths because cert provisioning is environmental
|
|
21
|
+
* (host's CA, client cert/key files). Follows the
|
|
22
|
+
* `restart-during-run.test.ts` opt-in precedent.
|
|
23
|
+
*
|
|
24
|
+
* Implementation note: this scenario uses `node:https.request` rather
|
|
25
|
+
* than the global `fetch` because conformance has no `undici` dep and
|
|
26
|
+
* Node's fetch doesn't expose a client-cert option without a dispatcher
|
|
27
|
+
* (which requires `undici` as a public package). `node:https` ships
|
|
28
|
+
* with the runtime and supports `{ cert, key, ca }` directly.
|
|
29
|
+
*
|
|
30
|
+
* Operator setup:
|
|
31
|
+
* OPENWOP_TEST_MTLS=1
|
|
32
|
+
* OPENWOP_TEST_MTLS_CLIENT_CERT_PATH=<path to PEM client cert>
|
|
33
|
+
* OPENWOP_TEST_MTLS_CLIENT_KEY_PATH=<path to PEM client key>
|
|
34
|
+
* OPENWOP_TEST_MTLS_CA_PATH=<optional path to CA bundle for server-cert verify>
|
|
35
|
+
* OPENWOP_BASE_URL=https://... (HTTPS required for mTLS)
|
|
36
|
+
*
|
|
37
|
+
* @see RFCS/0010-auth-profile-conformance.md §F
|
|
38
|
+
* @see spec/v1/auth-profiles.md §`openwop-auth-mtls`
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { describe, it, expect } from 'vitest';
|
|
42
|
+
import { readFileSync } from 'node:fs';
|
|
43
|
+
import { request as httpsRequest } from 'node:https';
|
|
44
|
+
import { driver } from '../lib/driver.js';
|
|
45
|
+
import { loadEnv } from '../lib/env.js';
|
|
46
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
47
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
48
|
+
|
|
49
|
+
interface MtlsCaps {
|
|
50
|
+
supported?: boolean;
|
|
51
|
+
required?: boolean;
|
|
52
|
+
subjectMapping?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface AuthCaps {
|
|
56
|
+
profiles?: string[];
|
|
57
|
+
mtls?: MtlsCaps;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const PROFILE = 'openwop-auth-mtls';
|
|
61
|
+
const FIXTURE = 'conformance-noop';
|
|
62
|
+
const RUN_BEHAVIOR = process.env.OPENWOP_TEST_MTLS === '1';
|
|
63
|
+
|
|
64
|
+
interface ClientCerts {
|
|
65
|
+
cert: Buffer;
|
|
66
|
+
key: Buffer;
|
|
67
|
+
ca?: Buffer;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface HttpsResponse {
|
|
71
|
+
status: number;
|
|
72
|
+
body: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readAuthCaps(): Promise<AuthCaps | undefined> {
|
|
76
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
77
|
+
return (disco.json as { capabilities?: { auth?: AuthCaps } }).capabilities?.auth;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isProfileAdvertised(auth: AuthCaps | undefined): boolean {
|
|
81
|
+
return (
|
|
82
|
+
Array.isArray(auth?.profiles) &&
|
|
83
|
+
auth.profiles.includes(PROFILE) &&
|
|
84
|
+
auth.mtls?.supported === true
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function loadClientCerts(): ClientCerts | undefined {
|
|
89
|
+
const certPath = process.env.OPENWOP_TEST_MTLS_CLIENT_CERT_PATH;
|
|
90
|
+
const keyPath = process.env.OPENWOP_TEST_MTLS_CLIENT_KEY_PATH;
|
|
91
|
+
if (!certPath || !keyPath) return undefined;
|
|
92
|
+
try {
|
|
93
|
+
const caPath = process.env.OPENWOP_TEST_MTLS_CA_PATH;
|
|
94
|
+
return {
|
|
95
|
+
cert: readFileSync(certPath),
|
|
96
|
+
key: readFileSync(keyPath),
|
|
97
|
+
...(caPath ? { ca: readFileSync(caPath) } : {}),
|
|
98
|
+
};
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// eslint-disable-next-line no-console
|
|
101
|
+
console.warn(`[auth-mtls] failed to read client cert/key: ${String(err)}`);
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function mtlsPost(
|
|
107
|
+
baseUrl: string,
|
|
108
|
+
path: string,
|
|
109
|
+
body: unknown,
|
|
110
|
+
headers: Record<string, string>,
|
|
111
|
+
certs: ClientCerts | undefined,
|
|
112
|
+
): Promise<HttpsResponse | { error: Error }> {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const url = new URL(baseUrl + path);
|
|
115
|
+
const payload = JSON.stringify(body);
|
|
116
|
+
const req = httpsRequest(
|
|
117
|
+
{
|
|
118
|
+
hostname: url.hostname,
|
|
119
|
+
port: url.port ? Number.parseInt(url.port, 10) : 443,
|
|
120
|
+
path: url.pathname + url.search,
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
...headers,
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
'Content-Length': Buffer.byteLength(payload).toString(),
|
|
126
|
+
},
|
|
127
|
+
...(certs ? { cert: certs.cert, key: certs.key } : {}),
|
|
128
|
+
...(certs?.ca ? { ca: certs.ca } : {}),
|
|
129
|
+
},
|
|
130
|
+
(res) => {
|
|
131
|
+
let chunks = '';
|
|
132
|
+
res.on('data', (c: Buffer | string) => {
|
|
133
|
+
chunks += typeof c === 'string' ? c : c.toString('utf8');
|
|
134
|
+
});
|
|
135
|
+
res.on('end', () => {
|
|
136
|
+
resolve({ status: res.statusCode ?? 0, body: chunks });
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
req.on('error', (error) => resolve({ error }));
|
|
141
|
+
req.write(payload);
|
|
142
|
+
req.end();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe('auth-mtls: capability shape', () => {
|
|
147
|
+
it('host claiming mTLS profile advertises required fields', async () => {
|
|
148
|
+
const auth = await readAuthCaps();
|
|
149
|
+
|
|
150
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
expect(auth?.profiles?.includes(PROFILE), driver.describe(
|
|
155
|
+
'auth-profiles.md §`openwop-auth-mtls`',
|
|
156
|
+
'capabilities.auth.profiles MUST include openwop-auth-mtls when the profile is claimed',
|
|
157
|
+
)).toBe(true);
|
|
158
|
+
|
|
159
|
+
expect(auth?.mtls?.supported, driver.describe(
|
|
160
|
+
'auth-profiles.md §`openwop-auth-mtls`',
|
|
161
|
+
'capabilities.auth.mtls.supported MUST be true when the profile is claimed',
|
|
162
|
+
)).toBe(true);
|
|
163
|
+
|
|
164
|
+
if (auth?.mtls?.required !== undefined) {
|
|
165
|
+
expect(
|
|
166
|
+
typeof auth.mtls.required,
|
|
167
|
+
'mtls.required MUST be boolean when advertised',
|
|
168
|
+
).toBe('boolean');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (auth?.mtls?.subjectMapping !== undefined) {
|
|
172
|
+
expect(
|
|
173
|
+
['cn', 'san-dns', 'san-uri'].includes(auth.mtls.subjectMapping),
|
|
174
|
+
driver.describe(
|
|
175
|
+
'capabilities.schema.json auth.mtls.subjectMapping',
|
|
176
|
+
'subjectMapping MUST be one of cn / san-dns / san-uri',
|
|
177
|
+
),
|
|
178
|
+
).toBe(true);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe.skipIf(!RUN_BEHAVIOR)('auth-mtls: client cert behavior', () => {
|
|
184
|
+
it('valid client cert + valid bearer → 201', async () => {
|
|
185
|
+
const auth = await readAuthCaps();
|
|
186
|
+
if (!isProfileAdvertised(auth)) return;
|
|
187
|
+
|
|
188
|
+
const certs = loadClientCerts();
|
|
189
|
+
if (!certs) {
|
|
190
|
+
// eslint-disable-next-line no-console
|
|
191
|
+
console.warn(
|
|
192
|
+
'[auth-mtls] OPENWOP_TEST_MTLS=1 but cert paths missing; skipping behavior',
|
|
193
|
+
);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!isFixtureAdvertised(FIXTURE)) return;
|
|
198
|
+
|
|
199
|
+
const env = loadEnv();
|
|
200
|
+
if (!env.baseUrl.startsWith('https://')) {
|
|
201
|
+
// eslint-disable-next-line no-console
|
|
202
|
+
console.warn(
|
|
203
|
+
`[auth-mtls] OPENWOP_BASE_URL is not HTTPS (got ${env.baseUrl}); mTLS requires HTTPS — skipping`,
|
|
204
|
+
);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const res = await mtlsPost(
|
|
209
|
+
env.baseUrl,
|
|
210
|
+
'/v1/runs',
|
|
211
|
+
{ workflowId: FIXTURE },
|
|
212
|
+
{ Authorization: `Bearer ${env.apiKey}` },
|
|
213
|
+
certs,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if ('error' in res) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`[auth-mtls] mTLS request failed at transport: ${res.error.message}`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
expect(res.status, driver.describe(
|
|
223
|
+
'auth-profiles.md §`openwop-auth-mtls`',
|
|
224
|
+
'valid client cert + valid bearer MUST authenticate POST /v1/runs (201)',
|
|
225
|
+
)).toBe(201);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('no client cert against mtls.required: true → non-2xx or TLS failure', async () => {
|
|
229
|
+
const auth = await readAuthCaps();
|
|
230
|
+
if (!isProfileAdvertised(auth)) return;
|
|
231
|
+
if (auth?.mtls?.required !== true) {
|
|
232
|
+
// eslint-disable-next-line no-console
|
|
233
|
+
console.warn(
|
|
234
|
+
'[auth-mtls] host advertises mtls.required: false; skipping no-cert rejection (host may accept bearer-only)',
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!isFixtureAdvertised(FIXTURE)) return;
|
|
240
|
+
|
|
241
|
+
const env = loadEnv();
|
|
242
|
+
if (!env.baseUrl.startsWith('https://')) {
|
|
243
|
+
// eslint-disable-next-line no-console
|
|
244
|
+
console.warn(
|
|
245
|
+
`[auth-mtls] OPENWOP_BASE_URL is not HTTPS (got ${env.baseUrl}); skipping no-cert rejection`,
|
|
246
|
+
);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// No certs supplied → either 4xx or transport-layer TLS handshake failure.
|
|
251
|
+
const res = await mtlsPost(
|
|
252
|
+
env.baseUrl,
|
|
253
|
+
'/v1/runs',
|
|
254
|
+
{ workflowId: FIXTURE },
|
|
255
|
+
{ Authorization: `Bearer ${env.apiKey}` },
|
|
256
|
+
undefined,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
if ('error' in res) {
|
|
260
|
+
// Transport-layer TLS failure is conformant per auth-profiles.md.
|
|
261
|
+
// The handshake rejected the client because no cert was offered.
|
|
262
|
+
expect(true, driver.describe(
|
|
263
|
+
'auth-profiles.md §`openwop-auth-mtls`',
|
|
264
|
+
'mtls.required: true MUST reject no-cert requests (TLS handshake failure is conformant)',
|
|
265
|
+
)).toBe(true);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
expect(res.status >= 400, driver.describe(
|
|
270
|
+
'auth-profiles.md §`openwop-auth-mtls`',
|
|
271
|
+
'mtls.required: true MUST reject no-cert requests at the auth layer (4xx) when not rejected at the TLS layer',
|
|
272
|
+
)).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0010 §C: openwop-auth-oauth2-client-credentials profile.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that hosts claiming the OAuth2-CC profile satisfy
|
|
5
|
+
* `spec/v1/auth-profiles.md` §`openwop-auth-oauth2-client-credentials`:
|
|
6
|
+
*
|
|
7
|
+
* 1. `capabilities.auth.profiles[]` includes
|
|
8
|
+
* `openwop-auth-oauth2-client-credentials` and `oauth2.supported`.
|
|
9
|
+
* 2. When advertised, `oauth2.issuer` is a non-empty URI, `audience`
|
|
10
|
+
* is a non-empty string, `supportedAlgorithms` is a non-empty
|
|
11
|
+
* array.
|
|
12
|
+
* 3. Malformed JWT bearer (not three dot-separated segments) returns
|
|
13
|
+
* 401 with the canonical error envelope.
|
|
14
|
+
* 4. Tokens minted by the conformance suite's synthetic OIDC issuer
|
|
15
|
+
* with deliberately-broken claims (wrong aud, expired exp,
|
|
16
|
+
* unsupported alg in header) return 401 when the host has been
|
|
17
|
+
* configured to trust the harness via `OPENWOP_TEST_OAUTH_ISSUER_TRUSTED=true`.
|
|
18
|
+
* 5. Positive token (`OPENWOP_TEST_OAUTH_TOKEN`) returns 201 on
|
|
19
|
+
* `POST /v1/runs` per the canonical run-create contract.
|
|
20
|
+
*
|
|
21
|
+
* Negative cases that require the host to trust the harness soft-skip
|
|
22
|
+
* when the operator hasn't wired up trust. The capability-shape and
|
|
23
|
+
* malformed-JWT assertions run unconditionally when the profile is
|
|
24
|
+
* advertised.
|
|
25
|
+
*
|
|
26
|
+
* @see RFCS/0010-auth-profile-conformance.md §C
|
|
27
|
+
* @see spec/v1/auth-profiles.md §`openwop-auth-oauth2-client-credentials`
|
|
28
|
+
* @see conformance/src/lib/oidc-issuer.ts — synthetic harness
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { driver } from '../lib/driver.js';
|
|
33
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
34
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
35
|
+
import { createSyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
36
|
+
|
|
37
|
+
interface OAuth2Caps {
|
|
38
|
+
supported?: boolean;
|
|
39
|
+
issuer?: string;
|
|
40
|
+
audience?: string;
|
|
41
|
+
supportedAlgorithms?: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface AuthCaps {
|
|
45
|
+
profiles?: string[];
|
|
46
|
+
oauth2?: OAuth2Caps;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PROFILE = 'openwop-auth-oauth2-client-credentials';
|
|
50
|
+
const FIXTURE = 'conformance-noop';
|
|
51
|
+
|
|
52
|
+
async function readAuthCaps(): Promise<AuthCaps | undefined> {
|
|
53
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
54
|
+
return (disco.json as { capabilities?: { auth?: AuthCaps } }).capabilities?.auth;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isProfileAdvertised(auth: AuthCaps | undefined): boolean {
|
|
58
|
+
return (
|
|
59
|
+
Array.isArray(auth?.profiles) &&
|
|
60
|
+
auth.profiles.includes(PROFILE) &&
|
|
61
|
+
auth.oauth2?.supported === true
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe('auth-oauth2-client-credentials: capability shape', () => {
|
|
66
|
+
it('host claiming OAuth2-CC profile advertises required fields', async () => {
|
|
67
|
+
const auth = await readAuthCaps();
|
|
68
|
+
|
|
69
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
expect(auth?.profiles?.includes(PROFILE), driver.describe(
|
|
74
|
+
'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
|
|
75
|
+
'capabilities.auth.profiles MUST include openwop-auth-oauth2-client-credentials when the profile is claimed',
|
|
76
|
+
)).toBe(true);
|
|
77
|
+
|
|
78
|
+
expect(auth?.oauth2?.supported, driver.describe(
|
|
79
|
+
'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
|
|
80
|
+
'capabilities.auth.oauth2.supported MUST be true when the profile is claimed',
|
|
81
|
+
)).toBe(true);
|
|
82
|
+
|
|
83
|
+
if (auth?.oauth2?.issuer !== undefined) {
|
|
84
|
+
expect(
|
|
85
|
+
typeof auth.oauth2.issuer === 'string' && auth.oauth2.issuer.length > 0,
|
|
86
|
+
driver.describe(
|
|
87
|
+
'capabilities.schema.json auth.oauth2.issuer',
|
|
88
|
+
'issuer MUST be a non-empty string when advertised',
|
|
89
|
+
),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (auth?.oauth2?.audience !== undefined) {
|
|
94
|
+
expect(
|
|
95
|
+
typeof auth.oauth2.audience === 'string' && auth.oauth2.audience.length > 0,
|
|
96
|
+
driver.describe(
|
|
97
|
+
'capabilities.schema.json auth.oauth2.audience',
|
|
98
|
+
'audience MUST be a non-empty string when advertised',
|
|
99
|
+
),
|
|
100
|
+
).toBe(true);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (auth?.oauth2?.supportedAlgorithms !== undefined) {
|
|
104
|
+
expect(
|
|
105
|
+
Array.isArray(auth.oauth2.supportedAlgorithms) &&
|
|
106
|
+
auth.oauth2.supportedAlgorithms.length > 0,
|
|
107
|
+
driver.describe(
|
|
108
|
+
'capabilities.schema.json auth.oauth2.supportedAlgorithms',
|
|
109
|
+
'supportedAlgorithms MUST be a non-empty array when advertised',
|
|
110
|
+
),
|
|
111
|
+
).toBe(true);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('auth-oauth2-client-credentials: malformed JWT rejected', () => {
|
|
117
|
+
it('returns 401 on bearer that is not a valid JWT shape', async () => {
|
|
118
|
+
const auth = await readAuthCaps();
|
|
119
|
+
|
|
120
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const res = await driver.post(
|
|
125
|
+
'/v1/runs',
|
|
126
|
+
{ workflowId: FIXTURE },
|
|
127
|
+
{
|
|
128
|
+
authenticated: false,
|
|
129
|
+
headers: { Authorization: 'Bearer not.a.real.jwt' },
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(res.status, driver.describe(
|
|
134
|
+
'auth.md §3',
|
|
135
|
+
'malformed JWT bearer MUST return 401 (canonical invalid_token envelope)',
|
|
136
|
+
)).toBe(401);
|
|
137
|
+
|
|
138
|
+
const body = res.json as { error?: unknown; message?: unknown } | undefined;
|
|
139
|
+
expect(typeof body?.error, driver.describe(
|
|
140
|
+
'auth.md §3 + rest-endpoints.md error envelope',
|
|
141
|
+
'response body MUST include `error` (machine code) string',
|
|
142
|
+
)).toBe('string');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('auth-oauth2-client-credentials: harness-minted negative cases', () => {
|
|
147
|
+
it('wrong-audience token returns 401 when host trusts the harness', async () => {
|
|
148
|
+
const auth = await readAuthCaps();
|
|
149
|
+
|
|
150
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (process.env.OPENWOP_TEST_OAUTH_ISSUER_TRUSTED !== 'true') {
|
|
155
|
+
// eslint-disable-next-line no-console
|
|
156
|
+
console.warn(
|
|
157
|
+
'[auth-oauth2-client-credentials] OPENWOP_TEST_OAUTH_ISSUER_TRUSTED not set; skipping harness-minted negative cases (operator must pre-configure the host to trust the conformance harness)',
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const issuerUrl =
|
|
163
|
+
process.env.OPENWOP_TEST_OAUTH_ISSUER_URL ?? 'http://127.0.0.1:0/oauth';
|
|
164
|
+
const audience = auth?.oauth2?.audience ?? 'openwop-conformance';
|
|
165
|
+
const issuer = createSyntheticOIDCIssuer({
|
|
166
|
+
issuer: issuerUrl,
|
|
167
|
+
audience,
|
|
168
|
+
algorithm: 'RS256',
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Wrong audience.
|
|
172
|
+
const wrongAud = issuer.mint({ aud: 'wrong-audience', sub: 'conformance-suite' });
|
|
173
|
+
const wrongAudRes = await driver.post(
|
|
174
|
+
'/v1/runs',
|
|
175
|
+
{ workflowId: FIXTURE },
|
|
176
|
+
{
|
|
177
|
+
authenticated: false,
|
|
178
|
+
headers: { Authorization: `Bearer ${wrongAud.token}` },
|
|
179
|
+
},
|
|
180
|
+
);
|
|
181
|
+
expect(wrongAudRes.status, driver.describe(
|
|
182
|
+
'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
|
|
183
|
+
'token with wrong aud claim MUST return 401',
|
|
184
|
+
)).toBe(401);
|
|
185
|
+
|
|
186
|
+
// Expired token.
|
|
187
|
+
const expired = issuer.mint(
|
|
188
|
+
{ sub: 'conformance-suite' },
|
|
189
|
+
{ expiresInSeconds: -3600 },
|
|
190
|
+
);
|
|
191
|
+
const expiredRes = await driver.post(
|
|
192
|
+
'/v1/runs',
|
|
193
|
+
{ workflowId: FIXTURE },
|
|
194
|
+
{
|
|
195
|
+
authenticated: false,
|
|
196
|
+
headers: { Authorization: `Bearer ${expired.token}` },
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
expect(expiredRes.status, driver.describe(
|
|
200
|
+
'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
|
|
201
|
+
'expired token (exp < now) MUST return 401',
|
|
202
|
+
)).toBe(401);
|
|
203
|
+
|
|
204
|
+
// Algorithm header lies (claims HS256, signature is RS256).
|
|
205
|
+
const algSpoofed = issuer.mint(
|
|
206
|
+
{ sub: 'conformance-suite' },
|
|
207
|
+
{ algorithm: 'HS256' },
|
|
208
|
+
);
|
|
209
|
+
const algSpoofedRes = await driver.post(
|
|
210
|
+
'/v1/runs',
|
|
211
|
+
{ workflowId: FIXTURE },
|
|
212
|
+
{
|
|
213
|
+
authenticated: false,
|
|
214
|
+
headers: { Authorization: `Bearer ${algSpoofed.token}` },
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
expect(algSpoofedRes.status, driver.describe(
|
|
218
|
+
'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
|
|
219
|
+
'token with alg outside supportedAlgorithms MUST return 401',
|
|
220
|
+
)).toBe(401);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe('auth-oauth2-client-credentials: positive token', () => {
|
|
225
|
+
it('operator-supplied valid token authenticates POST /v1/runs', async () => {
|
|
226
|
+
const auth = await readAuthCaps();
|
|
227
|
+
|
|
228
|
+
if (!behaviorGate(PROFILE, isProfileAdvertised(auth))) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const token = process.env.OPENWOP_TEST_OAUTH_TOKEN;
|
|
233
|
+
if (!token) {
|
|
234
|
+
// eslint-disable-next-line no-console
|
|
235
|
+
console.warn(
|
|
236
|
+
'[auth-oauth2-client-credentials] OPENWOP_TEST_OAUTH_TOKEN not supplied; skipping positive-path assertion',
|
|
237
|
+
);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!isFixtureAdvertised(FIXTURE)) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const res = await driver.post(
|
|
246
|
+
'/v1/runs',
|
|
247
|
+
{ workflowId: FIXTURE },
|
|
248
|
+
{
|
|
249
|
+
authenticated: false,
|
|
250
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
expect(res.status, driver.describe(
|
|
255
|
+
'auth-profiles.md §`openwop-auth-oauth2-client-credentials`',
|
|
256
|
+
'valid OAuth2-CC token MUST authenticate POST /v1/runs (201)',
|
|
257
|
+
)).toBe(201);
|
|
258
|
+
});
|
|
259
|
+
});
|