@openwop/openwop-conformance 2.35.0 → 2.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +3 -3
- package/coverage.md +13 -1
- package/dist/lib/certification-bundle-v3.js +21 -4
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
- package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
- package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
- package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
- package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
- package/fixtures/a2ui-v09/negative-image-component.json +145 -0
- package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
- package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
- package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
- package/fixtures/conformance-artifact-emit.json +28 -0
- package/fixtures/conformance-clarification-nested.json +53 -0
- package/fixtures/conformance-clarification-sensitive.json +49 -0
- package/fixtures/conformance-credential.json +25 -0
- package/fixtures/conformance-mcp-client.json +28 -0
- package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
- package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
- package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
- package/fixtures/node-pack-runtime/negative-headers.json +37 -0
- package/fixtures/node-pack-runtime/negative-http.json +31 -0
- package/fixtures/node-pack-runtime/negative-icons.json +37 -0
- package/fixtures/node-pack-runtime/negative-meta.json +36 -0
- package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
- package/fixtures/node-pack-runtime/negative-packages.json +41 -0
- package/fixtures/node-pack-runtime/negative-sse.json +31 -0
- package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
- package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
- package/fixtures/node-pack-runtime/negative-variables.json +38 -0
- package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
- package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
- package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
- package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
- package/fixtures/oauth-providers/synthetic.json +36 -4
- package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
- package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
- package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
- package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
- package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
- package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
- package/fixtures.md +139 -0
- package/package.json +2 -2
- package/requirements.json +4480 -97
- package/scenario-majors.json +93 -6
- package/schemas/CORPUS-STAMP.json +103 -89
- package/src/lib/behavior-gate.ts +7 -1
- package/src/lib/certification-bundle-v3.ts +18 -4
- package/src/lib/localized-content.ts +53 -0
- package/src/lib/mcp-fake-server.ts +105 -4
- package/src/lib/node-pack-runtime.ts +41 -0
- package/src/lib/oauth-as-double.ts +316 -0
- package/src/lib/protected-resource.ts +88 -0
- package/src/lib/standard-webhooks.ts +120 -0
- package/src/lib/toolCatalog.ts +54 -4
- package/src/lib/trace-context.ts +98 -0
- package/src/lib/v2.ts +6 -0
- package/src/lib/webhook-receiver.ts +201 -1
- package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
- package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
- package/src/scenarios/debugBundle.test.ts +41 -0
- package/src/scenarios/fixtures-valid.test.ts +72 -0
- package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
- package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
- package/src/scenarios/localized-content-delivery.test.ts +26 -15
- package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
- package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
- package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
- package/src/scenarios/tool-catalog-projection.test.ts +48 -12
- package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
- package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
- package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
- package/src/scenarios/v2-auth-challenge.test.ts +119 -0
- package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
- package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
- package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
- package/src/scenarios/v2-configurable-closed.test.ts +1 -1
- package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
- package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
- package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
- package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
- package/src/scenarios/v2-implementation-informational.test.ts +33 -0
- package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
- package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
- package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
- package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
- package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
- package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
- package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
- package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
- package/src/scenarios/v2-run-cancel.test.ts +1 -1
- package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
- package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
- package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
- package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
- package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
- package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
- package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
- package/src/scenarios/v2-webhook-standard-webhooks-delivery.test.ts +195 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0199 §A — the host as an OAuth authorization-code client
|
|
3
|
+
* (`spec/v2/core/oauth.md` §The authorization-code client). Target major 2.
|
|
4
|
+
*
|
|
5
|
+
* Gate: the v2 `oauth` record lists `authorization_code` and advertises the
|
|
6
|
+
* provider `synthetic`, and the host advertises the seams profile. The grant is
|
|
7
|
+
* driven through `POST /conformance/seams/sample/oauth/authorize-start`
|
|
8
|
+
* (api/seams-v2.yaml), which points the provider at the suite's
|
|
9
|
+
* authorization-server double and returns the URL the host's PRODUCTION
|
|
10
|
+
* authorization-URL builder produced; the suite then plays the user agent
|
|
11
|
+
* against the double and against the host's PRODUCTION callback. A seam that
|
|
12
|
+
* answers 404 is `blocked`, never a pass.
|
|
13
|
+
*
|
|
14
|
+
* The witness is the double's token-request counter (`lib/oauth-as-double.ts`):
|
|
15
|
+
* every "the host refused the callback" leg asserts ZERO new token requests at
|
|
16
|
+
* a server the suite owns, and each carries a positive control that the same
|
|
17
|
+
* path, unaltered, does make one — so a host that refuses everything fails too.
|
|
18
|
+
*
|
|
19
|
+
* Reachability: the host must reach the double. Front it with a public https
|
|
20
|
+
* tunnel (`OPENWOP_OAUTH_AS_URL`), or the host must relax its egress guard for
|
|
21
|
+
* loopback, which a bundle records as a relaxation.
|
|
22
|
+
*
|
|
23
|
+
* @see spec/v2/core/oauth.md §The authorization-code client
|
|
24
|
+
* @see RFCS/0199-outbound-oauth-client-and-credential-interrupt.md §A
|
|
25
|
+
* @see SECURITY/invariants.yaml id: oauth-same-user-binding
|
|
26
|
+
*/
|
|
27
|
+
import { describe, it, expect, afterAll } from 'vitest';
|
|
28
|
+
import { randomBytes } from 'node:crypto';
|
|
29
|
+
import { loadEnv } from '../lib/env.js';
|
|
30
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
31
|
+
import { req } from '../lib/requirement-ids.js';
|
|
32
|
+
import { S256, authorizeStart, authorizationUrlOf, consent, oauthGate, secondSubject, startAsDouble, userAgentGet, type AsDouble } from '../lib/oauth-as-double.js';
|
|
33
|
+
|
|
34
|
+
export const HOST_CALLBACK_NOT_REQUIRED = 'the suite plays the user agent: it reads the authorization URL from the authorize-start seam, consents at its own authorization-server double, and GETs the host\'s callback itself; the double is the only fixture the host reaches';
|
|
35
|
+
|
|
36
|
+
const DOC = 'spec/v2/core/oauth.md §The authorization-code client (RFC 0199 §A)';
|
|
37
|
+
const R = (slug: string): string => `openwop.requirement.0199.${slug}`;
|
|
38
|
+
const PROVIDER = 'synthetic';
|
|
39
|
+
const ISSUERLESS = ['synthetic-noiss', 'synthetic-noiss-b'];
|
|
40
|
+
const SCOPES = ['openwop.read'];
|
|
41
|
+
|
|
42
|
+
let double: AsDouble | null = null;
|
|
43
|
+
async function as(): Promise<AsDouble> { double ??= await startAsDouble(); return double; }
|
|
44
|
+
afterAll(async () => { await double?.close(); double = null; });
|
|
45
|
+
|
|
46
|
+
type Ready = { ok: true; d: AsDouble } | { ok: false; skip: () => undefined };
|
|
47
|
+
async function ready(providers: string[] = [PROVIDER]): Promise<Ready> {
|
|
48
|
+
const g = await oauthGate();
|
|
49
|
+
if (!g.ok) return { ok: false, skip: () => softSkip(g.kind, g.reason) };
|
|
50
|
+
const missing = providers.filter((p) => !g.providers.has(p));
|
|
51
|
+
if (missing.length > 0) return { ok: false, skip: () => softSkip('blocked', `oauth.providers does not list ${missing.join(', ')} — the suite's synthetic provider(s) (conformance/fixtures/oauth-providers/synthetic.json)`) };
|
|
52
|
+
return { ok: true, d: await as() };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const withIssuer = (d: AsDouble, extra: Record<string, unknown> = {}): Record<string, unknown> => ({ provider: PROVIDER, authUrl: d.authorizeUrl, tokenUrl: d.tokenUrl, issuer: d.issuer, scopes: SCOPES, ...extra });
|
|
56
|
+
|
|
57
|
+
/** authorize-start → the authorization URL, or a recorded skip/fail. */
|
|
58
|
+
async function start(body: Record<string, unknown>, id: string, bearer?: string): Promise<URL | 'absent'> {
|
|
59
|
+
const r = await authorizeStart(body, bearer);
|
|
60
|
+
if (r.status === 404 || r.status === 405) return 'absent';
|
|
61
|
+
const url = authorizationUrlOf(r);
|
|
62
|
+
expect(url, req(id, 'api/seams-v2.yaml startOAuthAuthorization', `authorize-start MUST answer 201 { authorizationUrl } for an advertised provider (got ${r.status} ${r.text.slice(0, 300)})`)).not.toBeNull();
|
|
63
|
+
return url as URL;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe('RFC 0199 §A — v2-oauth-client-pkce-state-iss (host as OAuth client, gated on oauth + authorization_code + seams)', () => {
|
|
67
|
+
it('sends PKCE S256 and the verifier at the token endpoint hashes to the challenge', async () => {
|
|
68
|
+
const r = await ready();
|
|
69
|
+
if (!r.ok) return r.skip();
|
|
70
|
+
const { d } = r;
|
|
71
|
+
const id = R('pkce-s256');
|
|
72
|
+
const url = await start(withIssuer(d), id);
|
|
73
|
+
if (url === 'absent') return seamAbsent('POST /conformance/seams/sample/oauth/authorize-start is not mounted — the grant cannot be driven against the suite\'s double');
|
|
74
|
+
expect(`${url.origin}${url.pathname}`, req(id, DOC, 'the authorization URL MUST be the provider\'s authorization endpoint (the double\'s)')).toBe(d.authorizeUrl);
|
|
75
|
+
expect(url.searchParams.get('code_challenge_method'), req(id, `${DOC} rule 1`, `PKCE MUST use S256, never plain (got ${url.searchParams.get('code_challenge_method')})`)).toBe('S256');
|
|
76
|
+
const challenge = url.searchParams.get('code_challenge') ?? '';
|
|
77
|
+
expect(/^[A-Za-z0-9_-]{43}$/.test(challenge), req(id, `${DOC} rule 1; RFC 7636 §4.2`, `code_challenge MUST be BASE64URL(SHA256(verifier)) — 43 characters (got ${JSON.stringify(challenge)})`)).toBe(true);
|
|
78
|
+
const callback = await consent(url.toString());
|
|
79
|
+
expect(callback, req(id, DOC, 'the double answers the authorization request with a redirect to the host\'s redirect URI')).not.toBeNull();
|
|
80
|
+
const before = d.codeExchanges().length;
|
|
81
|
+
const cb = await userAgentGet(callback!, loadEnv().apiKey);
|
|
82
|
+
expect(cb.status, req(id, DOC, `positive control: the unaltered callback, authenticated as the initiating Subject, MUST complete (got ${cb.status} ${cb.text.slice(0, 200)})`)).toBeLessThan(400);
|
|
83
|
+
const exchanges = d.codeExchanges().slice(before);
|
|
84
|
+
expect(exchanges.length, req(id, DOC, 'the completed callback makes exactly one token request at the provider')).toBe(1);
|
|
85
|
+
const verifier = exchanges[0]!.params['code_verifier'] ?? '';
|
|
86
|
+
expect(verifier.length >= 43 && S256(verifier) === challenge, req(id, `${DOC} rule 1; RFC 7636 §4.6`, 'the code_verifier sent to the token endpoint MUST hash (S256) to the challenge sent in the authorization request')).toBe(true);
|
|
87
|
+
expect(exchanges[0]!.status, req(id, DOC, `the double accepted the exchange (${exchanges[0]!.refused ?? 'ok'})`)).toBe(200);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('sends a fresh state per grant, and a forged, missing or replayed state makes no token request', async () => {
|
|
91
|
+
const r = await ready();
|
|
92
|
+
if (!r.ok) return r.skip();
|
|
93
|
+
const { d } = r;
|
|
94
|
+
const id = R('state-single-use');
|
|
95
|
+
const a = await start(withIssuer(d), id);
|
|
96
|
+
if (a === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
97
|
+
const b = await start(withIssuer(d), id);
|
|
98
|
+
if (b === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
99
|
+
const sa = a.searchParams.get('state') ?? ''; const sb = b.searchParams.get('state') ?? '';
|
|
100
|
+
expect(sa.length >= 22 && sb.length >= 22, req(id, `${DOC} rule 2`, `state MUST be present and carry at least 128 bits (got ${sa.length} and ${sb.length} characters)`)).toBe(true);
|
|
101
|
+
expect(sa, req(id, `${DOC} rule 2`, 'state MUST be fresh for every grant')).not.toBe(sb);
|
|
102
|
+
const cbA = await consent(a.toString());
|
|
103
|
+
const cbB = await consent(b.toString());
|
|
104
|
+
expect(cbA !== null && cbB !== null, req(id, DOC, 'the double redirects both grants to the host')).toBe(true);
|
|
105
|
+
const key = loadEnv().apiKey;
|
|
106
|
+
|
|
107
|
+
const forged = new URL(cbA!.toString()); forged.searchParams.set('state', randomBytes(24).toString('base64url'));
|
|
108
|
+
let n = d.codeExchanges().length;
|
|
109
|
+
const f = await userAgentGet(forged, key);
|
|
110
|
+
expect([f.status >= 400, d.codeExchanges().length], req(id, `${DOC} rule 2`, `a callback whose state is unknown MUST be refused with no token request (got ${f.status}, ${d.codeExchanges().length - n} token request(s))`)).toEqual([true, n]);
|
|
111
|
+
|
|
112
|
+
const bare = new URL(cbB!.toString()); bare.searchParams.delete('state');
|
|
113
|
+
const m = await userAgentGet(bare, key);
|
|
114
|
+
expect([m.status >= 400, d.codeExchanges().length], req(id, `${DOC} rule 2`, `a callback with no state MUST be refused with no token request (got ${m.status})`)).toEqual([true, n]);
|
|
115
|
+
|
|
116
|
+
const ok = await userAgentGet(cbA!, key);
|
|
117
|
+
expect([ok.status < 400, d.codeExchanges().length], req(id, DOC, `positive control: the genuine callback completes with one token request (got ${ok.status} ${ok.text.slice(0, 200)})`)).toEqual([true, n + 1]);
|
|
118
|
+
n = d.codeExchanges().length;
|
|
119
|
+
const replay = await userAgentGet(cbA!, key);
|
|
120
|
+
expect([replay.status >= 400, d.codeExchanges().length], req(id, `${DOC} rule 2`, `a replayed (already consumed) state MUST be refused with no token request (got ${replay.status})`)).toEqual([true, n]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('a callback authenticated as another Subject makes no token request and stores nothing', async () => {
|
|
124
|
+
const r = await ready();
|
|
125
|
+
if (!r.ok) return r.skip();
|
|
126
|
+
const { d } = r;
|
|
127
|
+
const id = R('same-user-callback');
|
|
128
|
+
const other = await secondSubject();
|
|
129
|
+
if (other === null) return softSkip('blocked', 'no second Subject: the credential mint seam is not mounted and OPENWOP_TEST_TENANT_B_API_KEY is not set');
|
|
130
|
+
const url = await start(withIssuer(d), id);
|
|
131
|
+
if (url === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
132
|
+
const cb = await consent(url.toString());
|
|
133
|
+
expect(cb, req(id, DOC, 'the double redirects the grant to the host')).not.toBeNull();
|
|
134
|
+
const n = d.codeExchanges().length;
|
|
135
|
+
const wrong = await userAgentGet(cb!, other.bearer);
|
|
136
|
+
expect([wrong.status >= 400, d.codeExchanges().length], req(id, `${DOC} rule 3; invariant oauth-same-user-binding`, `the callback of Subject A's grant, authenticated as another Subject (${other.how}), MUST be refused and make no token request (got ${wrong.status}, ${d.codeExchanges().length - n} token request(s))`)).toEqual([true, n]);
|
|
137
|
+
// Positive control on the same path: a grant the second Subject itself started completes for it.
|
|
138
|
+
const own = await start(withIssuer(d), id, other.bearer);
|
|
139
|
+
if (own === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
140
|
+
const ownCb = await consent(own.toString());
|
|
141
|
+
const done = await userAgentGet(ownCb!, other.bearer);
|
|
142
|
+
expect([done.status < 400, d.codeExchanges().length], req(id, DOC, `positive control: the second Subject's own grant completes for it with one token request (got ${done.status} ${done.text.slice(0, 200)})`)).toEqual([true, n + 1]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('a wrong or missing iss (with authorization_response_iss_parameter_supported) makes no token request', async () => {
|
|
146
|
+
const r = await ready();
|
|
147
|
+
if (!r.ok) return r.skip();
|
|
148
|
+
const { d } = r;
|
|
149
|
+
const id = R('iss-validated');
|
|
150
|
+
d.issParameterSupported = true;
|
|
151
|
+
const key = loadEnv().apiKey;
|
|
152
|
+
const one = await start(withIssuer(d), id);
|
|
153
|
+
if (one === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
154
|
+
const cb1 = await consent(one.toString());
|
|
155
|
+
const wrong = new URL(cb1!.toString()); wrong.searchParams.set('iss', `${d.issuer}/evil`);
|
|
156
|
+
let n = d.codeExchanges().length;
|
|
157
|
+
const w = await userAgentGet(wrong, key);
|
|
158
|
+
expect([w.status >= 400, d.codeExchanges().length], req(id, `${DOC} rule 4; RFC 9207 §2.4`, `a response whose iss differs from the provider's issuer MUST be refused before any token request (got ${w.status})`)).toEqual([true, n]);
|
|
159
|
+
const two = await start(withIssuer(d), id);
|
|
160
|
+
if (two === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
161
|
+
const cb2 = await consent(two.toString());
|
|
162
|
+
const missing = new URL(cb2!.toString()); missing.searchParams.delete('iss');
|
|
163
|
+
const m = await userAgentGet(missing, key);
|
|
164
|
+
expect([m.status >= 400, d.codeExchanges().length], req(id, `${DOC} rule 4; RFC 9207 §2.4`, `a response without iss from a provider whose metadata sets authorization_response_iss_parameter_supported MUST be refused before any token request (got ${m.status})`)).toEqual([true, n]);
|
|
165
|
+
const three = await start(withIssuer(d), id);
|
|
166
|
+
if (three === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
167
|
+
const cb3 = await consent(three.toString());
|
|
168
|
+
const good = await userAgentGet(cb3!, key);
|
|
169
|
+
n += 1;
|
|
170
|
+
expect([good.status < 400, d.codeExchanges().length], req(id, DOC, `positive control: the same grant with the issuer's own iss completes (got ${good.status} ${good.text.slice(0, 200)})`)).toEqual([true, n]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('issuer-less providers get redirect URIs no other provider shares', async () => {
|
|
174
|
+
const r = await ready([PROVIDER, ...ISSUERLESS]);
|
|
175
|
+
if (!r.ok) return r.skip();
|
|
176
|
+
const { d } = r;
|
|
177
|
+
const id = R('iss-validated');
|
|
178
|
+
const uris: string[] = [];
|
|
179
|
+
for (const provider of [PROVIDER, ...ISSUERLESS]) {
|
|
180
|
+
const body = provider === PROVIDER ? withIssuer(d) : { provider, authUrl: d.authorizeUrl, tokenUrl: d.tokenUrl, scopes: SCOPES };
|
|
181
|
+
const url = await start(body, id);
|
|
182
|
+
if (url === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
183
|
+
uris.push(url.searchParams.get('redirect_uri') ?? '');
|
|
184
|
+
}
|
|
185
|
+
expect(uris.every((u) => u.length > 0), req(id, DOC, 'every authorization URL carries redirect_uri')).toBe(true);
|
|
186
|
+
expect(new Set(uris).size, req(id, `${DOC} rule 4; RFC 9700 §4.4.2.2`, `a provider with no known issuer MUST get a redirect URI no other provider on the host shares (got ${JSON.stringify(uris)})`)).toBe(uris.length);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('the redirect URI is fixed per provider whatever the request carries', async () => {
|
|
190
|
+
const r = await ready();
|
|
191
|
+
if (!r.ok) return r.skip();
|
|
192
|
+
const { d } = r;
|
|
193
|
+
const id = R('redirect-uri-fixed');
|
|
194
|
+
const first = await start(withIssuer(d), id);
|
|
195
|
+
if (first === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
196
|
+
const probe = 'https://attacker.example/openwop/oauth/callback';
|
|
197
|
+
const second = await start(withIssuer(d, { redirectUri: probe }), id);
|
|
198
|
+
if (second === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
199
|
+
const a = first.searchParams.get('redirect_uri'); const b = second.searchParams.get('redirect_uri');
|
|
200
|
+
expect(b, req(id, `${DOC} rule 5`, 'the redirect URI MUST NOT be derived from request input')).not.toBe(probe);
|
|
201
|
+
expect(b, req(id, `${DOC} rule 5`, `one fixed redirect URI per provider: two grants for one provider carry the same redirect_uri (got ${a} and ${b})`)).toBe(a);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0199 §B / §E.2 — a provider reached as an MCP server
|
|
3
|
+
* (`spec/v2/core/oauth.md` §The authorization-code client, MCP-reach
|
|
4
|
+
* paragraph; `connection-packs.md` §Manifest clause 3a). Target major 2.
|
|
5
|
+
*
|
|
6
|
+
* Gate: the oauth gate of `lib/oauth-as-double.ts` (oauth + authorization_code
|
|
7
|
+
* + seams). Each leg hands the host a `reach.mcp` + `oauth2` connection pack
|
|
8
|
+
* through `authorize-start`'s `connection` member, which the host registers
|
|
9
|
+
* through its PRODUCTION connection-pack path.
|
|
10
|
+
*
|
|
11
|
+
* - The two host-side refusals (§E.2: no `issuer`; `pkce: "unsupported"`)
|
|
12
|
+
* need nothing reachable — the host must refuse before it sends anything —
|
|
13
|
+
* so they run on any host.
|
|
14
|
+
* - The discovery legs (`resource`, verify-not-select, S256 in metadata,
|
|
15
|
+
* pinning) need the suite's authorization-server doubles and its
|
|
16
|
+
* protected-resource double reachable over https, because a connection
|
|
17
|
+
* pack's endpoints and server URL are `https://` by schema. Without the
|
|
18
|
+
* public fronts (`OPENWOP_OAUTH_AS_URL`, `OPENWOP_OAUTH_AS2_URL`,
|
|
19
|
+
* `OPENWOP_OAUTH_RESOURCE_URL`) they record `blocked`.
|
|
20
|
+
*
|
|
21
|
+
* The witness is the doubles' own counters: a refused discovery shows ZERO
|
|
22
|
+
* requests at the foreign issuer the PRM named and ZERO token requests.
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v2/core/oauth.md §The authorization-code client
|
|
25
|
+
* @see spec/v1/connection-packs.md §Manifest clause 3a
|
|
26
|
+
* @see RFCS/0199-outbound-oauth-client-and-credential-interrupt.md §B, §E.2
|
|
27
|
+
*/
|
|
28
|
+
import { describe, it, expect, afterAll } from 'vitest';
|
|
29
|
+
import { randomBytes } from 'node:crypto';
|
|
30
|
+
import { loadEnv } from '../lib/env.js';
|
|
31
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
32
|
+
import { softSkip, seamAbsent } from '../lib/soft-skip.js';
|
|
33
|
+
import { req } from '../lib/requirement-ids.js';
|
|
34
|
+
import { v2Validator } from '../lib/v2.js';
|
|
35
|
+
import { authorizeStart, authorizationUrlOf, consent, oauthGate, startAsDouble, startResourceDouble, userAgentGet, type AsDouble, type ResourceDouble } from '../lib/oauth-as-double.js';
|
|
36
|
+
|
|
37
|
+
export const HOST_CALLBACK_NOT_REQUIRED = 'the suite hands the host a connection pack through authorize-start and plays the user agent; the host reaches only the suite\'s authorization-server and protected-resource doubles';
|
|
38
|
+
|
|
39
|
+
const DOC = 'spec/v2/core/oauth.md §The authorization-code client (MCP reach; RFC 0199 §B)';
|
|
40
|
+
const R = (slug: string): string => `openwop.requirement.0199.${slug}`;
|
|
41
|
+
const validPack = v2Validator('connection-pack-manifest');
|
|
42
|
+
|
|
43
|
+
const opened: Array<{ close(): Promise<void> }> = [];
|
|
44
|
+
afterAll(async () => { for (const o of opened.splice(0)) await o.close(); });
|
|
45
|
+
|
|
46
|
+
function pack(opts: { id: string; serverUrl: string; authorize: string; token: string; issuer?: string; pkce?: string }): Record<string, unknown> {
|
|
47
|
+
const auth: Record<string, unknown> = { kind: 'oauth2', authFlow: 'pkce', scopeModel: 'coarse', endpoints: { authorize: opts.authorize, token: opts.token } };
|
|
48
|
+
if (opts.issuer !== undefined) auth['issuer'] = opts.issuer;
|
|
49
|
+
if (opts.pkce !== undefined) auth['pkce'] = opts.pkce;
|
|
50
|
+
return {
|
|
51
|
+
name: `vendor.openwop-conformance.connections.${opts.id}`,
|
|
52
|
+
version: '1.0.0',
|
|
53
|
+
kind: 'connection',
|
|
54
|
+
engines: { openwop: '>=2.0.0' },
|
|
55
|
+
provider: { id: opts.id, displayName: `Conformance ${opts.id}`, category: 'other', auth, reach: { mcp: { server: { url: opts.serverUrl, transport: 'http' } } } },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const uniqueId = (tag: string): string => `conf-${tag}-${randomBytes(4).toString('hex')}`;
|
|
59
|
+
|
|
60
|
+
type Env = { ok: true; as: AsDouble; foreign: AsDouble; resource: ResourceDouble } | { ok: false; kind: 'blocked'; reason: string };
|
|
61
|
+
let started: Env | null = null;
|
|
62
|
+
async function doubles(): Promise<Env> {
|
|
63
|
+
started ??= await startDoubles();
|
|
64
|
+
return started;
|
|
65
|
+
}
|
|
66
|
+
async function startDoubles(): Promise<Env> {
|
|
67
|
+
const as = await startAsDouble('OPENWOP_OAUTH_AS_URL');
|
|
68
|
+
const foreign = await startAsDouble('OPENWOP_OAUTH_AS2_URL');
|
|
69
|
+
const resource = await startResourceDouble([as.issuer]);
|
|
70
|
+
opened.push(as, foreign, resource);
|
|
71
|
+
if (!as.tunnelled || !foreign.tunnelled || !resource.tunnelled) {
|
|
72
|
+
return { ok: false, kind: 'blocked', reason: 'a connection pack names https endpoints and an https MCP server URL, so the discovery legs need the suite\'s doubles behind public https fronts — set OPENWOP_OAUTH_AS_URL, OPENWOP_OAUTH_AS2_URL and OPENWOP_OAUTH_RESOURCE_URL (a tunnel or TLS-terminating proxy per double)' };
|
|
73
|
+
}
|
|
74
|
+
return { ok: true, as, foreign, resource };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function startWith(p: Record<string, unknown>): Promise<{ status: number; code: string | undefined; url: URL | null } | 'absent'> {
|
|
78
|
+
const id = String((p['provider'] as { id: string }).id);
|
|
79
|
+
const r = await authorizeStart({ provider: id, connection: p });
|
|
80
|
+
if (r.status === 404 || r.status === 405) return 'absent';
|
|
81
|
+
return { status: r.status, code: readErrorCode(r.json), url: authorizationUrlOf(r) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('RFC 0199 §B — v2-oauth-mcp-reach-discovery (gated on oauth + authorization_code + seams)', () => {
|
|
85
|
+
it('a reach.mcp + oauth2 pack without issuer validates, and its grant is refused with no authorization URL', async () => {
|
|
86
|
+
const g = await oauthGate();
|
|
87
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
88
|
+
const id = R('mcp-metadata-bound');
|
|
89
|
+
const p = pack({ id: uniqueId('noiss'), serverUrl: 'https://mcp.conformance.invalid/mcp', authorize: 'https://as.conformance.invalid/authorize', token: 'https://as.conformance.invalid/token' });
|
|
90
|
+
const v = validPack(p);
|
|
91
|
+
expect(v.ok, req(id, 'schemas/v2/connection-pack-manifest.schema.json (issuer stays optional, RFC 0199 §E.2)', `an issuer-less reach.mcp + oauth2 manifest MUST still validate — the rule refuses a grant, never a document: ${v.errors}`)).toBe(true);
|
|
92
|
+
const r = await startWith(p);
|
|
93
|
+
if (r === 'absent') return seamAbsent('POST /conformance/seams/sample/oauth/authorize-start is not mounted');
|
|
94
|
+
expect([r.status, r.code, r.url], req(id, `${DOC}; RFC 0199 §E.2`, `a host MUST NOT authorize a reach.mcp + oauth2 provider whose manifest declares no issuer: 422 connection_auth_metadata_mismatch before any authorization URL (got ${r.status} ${r.code})`)).toEqual([422, 'connection_auth_metadata_mismatch', null]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('a reach.mcp provider declaring pkce unsupported is refused with no authorization URL', async () => {
|
|
98
|
+
const g = await oauthGate();
|
|
99
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
100
|
+
const id = R('mcp-pkce-verified');
|
|
101
|
+
const p = pack({ id: uniqueId('nopkce'), serverUrl: 'https://mcp.conformance.invalid/mcp', authorize: 'https://as.conformance.invalid/authorize', token: 'https://as.conformance.invalid/token', issuer: 'https://as.conformance.invalid', pkce: 'unsupported' });
|
|
102
|
+
const r = await startWith(p);
|
|
103
|
+
if (r === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
104
|
+
expect([r.status, r.code, r.url], req(id, `${DOC}; RFC 0199 §B.2/§E.2`, `a provider reached as an MCP server MUST use PKCE: pkce unsupported is refused 422 connection_auth_metadata_mismatch before any authorization URL (got ${r.status} ${r.code})`)).toEqual([422, 'connection_auth_metadata_mismatch', null]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('sends resource (RFC 8707) equal to the canonical server URI on the authorization and the token request', async () => {
|
|
108
|
+
const g = await oauthGate();
|
|
109
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
110
|
+
const e = await doubles();
|
|
111
|
+
if (!e.ok) return softSkip(e.kind, e.reason);
|
|
112
|
+
const id = R('resource-indicator');
|
|
113
|
+
const p = pack({ id: uniqueId('res'), serverUrl: e.resource.resource, authorize: e.as.authorizeUrl, token: e.as.tokenUrl, issuer: e.as.issuer });
|
|
114
|
+
const r = await startWith(p);
|
|
115
|
+
if (r === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
116
|
+
expect(r.url, req(id, DOC, `a verified reach.mcp provider is authorized: 201 with an authorization URL (got ${r.status} ${r.code})`)).not.toBeNull();
|
|
117
|
+
expect(r.url!.searchParams.get('resource'), req(id, `${DOC}; RFC 8707 §2`, 'the authorization request MUST carry resource = the canonical server URI')).toBe(e.resource.resource);
|
|
118
|
+
const cb = await consent(r.url!.toString());
|
|
119
|
+
const n = e.as.codeExchanges().length;
|
|
120
|
+
const done = await userAgentGet(cb!, loadEnv().apiKey);
|
|
121
|
+
const ex = e.as.codeExchanges().slice(n);
|
|
122
|
+
expect([done.status < 400, ex.length], req(id, DOC, `the callback completes with one token request (got ${done.status})`)).toEqual([true, 1]);
|
|
123
|
+
expect(ex[0]!.params['resource'], req(id, `${DOC}; RFC 8707 §2`, 'the token request MUST carry the same resource')).toBe(e.resource.resource);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('discovery verifies and never selects: a PRM naming another issuer, or metadata naming another token endpoint, is refused and contacted nowhere', async () => {
|
|
127
|
+
const g = await oauthGate();
|
|
128
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
129
|
+
const e = await doubles();
|
|
130
|
+
if (!e.ok) return softSkip(e.kind, e.reason);
|
|
131
|
+
const id = R('mcp-metadata-bound');
|
|
132
|
+
e.resource.prm = { ...e.resource.prm, authorization_servers: [e.foreign.issuer] };
|
|
133
|
+
const tokensBefore = e.as.tokenRequests.length;
|
|
134
|
+
const one = await startWith(pack({ id: uniqueId('prm'), serverUrl: e.resource.resource, authorize: e.as.authorizeUrl, token: e.as.tokenUrl, issuer: e.as.issuer }));
|
|
135
|
+
if (one === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
136
|
+
expect([one.status, one.code, one.url], req(id, `${DOC} (discovery verifies; it never selects)`, `a PRM whose authorization_servers does not contain the manifest's issuer MUST be refused 422 connection_auth_metadata_mismatch (got ${one.status} ${one.code})`)).toEqual([422, 'connection_auth_metadata_mismatch', null]);
|
|
137
|
+
expect([e.foreign.metadataHits.length, e.foreign.authorizeRequests.length, e.foreign.tokenRequests.length], req(id, DOC, 'the foreign issuer the PRM named MUST receive zero requests — discovery never selects an endpoint')).toEqual([0, 0, 0]);
|
|
138
|
+
e.resource.prm = { ...e.resource.prm, authorization_servers: [e.as.issuer] };
|
|
139
|
+
e.as.metadataOverride = { token_endpoint: `${e.as.issuer}/token-elsewhere` };
|
|
140
|
+
try {
|
|
141
|
+
const two = await startWith(pack({ id: uniqueId('asmd'), serverUrl: e.resource.resource, authorize: e.as.authorizeUrl, token: e.as.tokenUrl, issuer: e.as.issuer }));
|
|
142
|
+
if (two === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
143
|
+
expect([two.status, two.code, two.url], req(id, DOC, `authorization-server metadata whose token_endpoint differs from the manifest MUST be refused (got ${two.status} ${two.code})`)).toEqual([422, 'connection_auth_metadata_mismatch', null]);
|
|
144
|
+
} finally {
|
|
145
|
+
e.as.metadataOverride = {};
|
|
146
|
+
}
|
|
147
|
+
expect(e.as.tokenRequests.length, req(id, DOC, 'a refused discovery sends no token request')).toBe(tokensBefore);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('refuses a provider whose authorization-server metadata does not list S256', async () => {
|
|
151
|
+
const g = await oauthGate();
|
|
152
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
153
|
+
const e = await doubles();
|
|
154
|
+
if (!e.ok) return softSkip(e.kind, e.reason);
|
|
155
|
+
const id = R('mcp-pkce-verified');
|
|
156
|
+
e.as.codeChallengeMethods = ['plain'];
|
|
157
|
+
try {
|
|
158
|
+
const r = await startWith(pack({ id: uniqueId('plain'), serverUrl: e.resource.resource, authorize: e.as.authorizeUrl, token: e.as.tokenUrl, issuer: e.as.issuer }));
|
|
159
|
+
if (r === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
160
|
+
expect([r.status, r.url], req(id, `${DOC}; RFC 0199 §B.2`, `metadata whose code_challenge_methods_supported lacks S256 MUST be refused with no authorization URL (got ${r.status} ${r.code})`)).toEqual([422, null]);
|
|
161
|
+
} finally {
|
|
162
|
+
e.as.codeChallengeMethods = ['S256'];
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('pins the verified tuple at registration and refuses a later discovery that disagrees', async () => {
|
|
167
|
+
const g = await oauthGate();
|
|
168
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
169
|
+
const e = await doubles();
|
|
170
|
+
if (!e.ok) return softSkip(e.kind, e.reason);
|
|
171
|
+
const id = R('mcp-metadata-bound');
|
|
172
|
+
const p = pack({ id: uniqueId('pin'), serverUrl: e.resource.resource, authorize: e.as.authorizeUrl, token: e.as.tokenUrl, issuer: e.as.issuer });
|
|
173
|
+
const first = await startWith(p);
|
|
174
|
+
if (first === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
175
|
+
expect(first.url, req(id, DOC, `positive control: the pack verifies and is authorized at registration (got ${first.status} ${first.code})`)).not.toBeNull();
|
|
176
|
+
e.resource.prm = { ...e.resource.prm, authorization_servers: [e.foreign.issuer] };
|
|
177
|
+
try {
|
|
178
|
+
const later = await startWith(p);
|
|
179
|
+
if (later === 'absent') return seamAbsent('authorize-start is not mounted');
|
|
180
|
+
expect([later.status, later.code, later.url], req(id, `${DOC} (pinned at registration, RFC 0199 §B.4)`, `a PRM changed after registration (now naming another issuer) MUST be refused on the next grant, not adopted silently (got ${later.status} ${later.code})`)).toEqual([422, 'connection_auth_metadata_mismatch', null]);
|
|
181
|
+
expect(e.foreign.metadataHits.length + e.foreign.tokenRequests.length, req(id, DOC, 'the newly named issuer is contacted nowhere')).toBe(0);
|
|
182
|
+
} finally {
|
|
183
|
+
e.resource.prm = { ...e.resource.prm, authorization_servers: [e.as.issuer] };
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0200 §D — on the `oidc` lane an ID token MAY be a bearer only when its `aud` equals
|
|
3
|
+
* the host's configured audience (suite 2.36.0, target major 2).
|
|
4
|
+
*
|
|
5
|
+
* The corpus never retired ID tokens as bearers, and retiring them would break both
|
|
6
|
+
* production hosts (MyndHyve's `oidc` lane names `securetoken.google.com/…`; openwop-app
|
|
7
|
+
* verifies Firebase ID tokens). The actual risk was never the ID token — it is the
|
|
8
|
+
* AUDIENCE: an ID token minted for some OTHER relying party at the same IdP, presented
|
|
9
|
+
* here, is the substitution attack `threat-model-auth-profiles.md` A2 names. §D names the
|
|
10
|
+
* ID-token case explicitly as admissible under the audience MUST `identity.md` §2.1
|
|
11
|
+
* already carries, and refuses everything else.
|
|
12
|
+
*
|
|
13
|
+
* **Gate:** an advertised `oidc` lane AND `OPENWOP_TEST_OIDC_ISSUER_URL` naming a
|
|
14
|
+
* synthetic issuer the host trusts as that lane's trust root. Without the harness issuer
|
|
15
|
+
* the suite cannot mint a token the host would ever accept, so the leg is `blocked` — an
|
|
16
|
+
* assertion that a random string is refused would witness nothing.
|
|
17
|
+
*
|
|
18
|
+
* `HOST_CALLBACK_NOT_REQUIRED`: the suite stands the issuer up itself and the host reaches
|
|
19
|
+
* it for JWKS; nothing calls back into the suite's own API.
|
|
20
|
+
*
|
|
21
|
+
* How it FAILS (the sabotage run before citing the row):
|
|
22
|
+
* (a) the host skips the `aud` check for ID tokens (the "tempted to relax" case — an ID
|
|
23
|
+
* token is not an access token, so its audience feels like the IdP's business) → the
|
|
24
|
+
* foreign-audience leg is served instead of refused.
|
|
25
|
+
*
|
|
26
|
+
* @see spec/v2/core/identity.md §2.1
|
|
27
|
+
* @see RFCS/0200-host-as-oauth-protected-resource.md §D
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { afterAll, describe, it, expect } from 'vitest';
|
|
31
|
+
import { randomBytes } from 'node:crypto';
|
|
32
|
+
import { createServer, type Server } from 'node:http';
|
|
33
|
+
import { driver } from '../lib/driver.js';
|
|
34
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
35
|
+
import { req } from '../lib/requirement-ids.js';
|
|
36
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
37
|
+
import { createSyntheticOIDCIssuer, type SyntheticOIDCIssuer } from '../lib/oidc-issuer.js';
|
|
38
|
+
import { prmGate } from '../lib/protected-resource.js';
|
|
39
|
+
|
|
40
|
+
export const HOST_CALLBACK_NOT_REQUIRED =
|
|
41
|
+
'the suite stands up the synthetic OIDC issuer and the host fetches its JWKS; no request returns to the suite\'s own API, so no host-reachable callback is needed';
|
|
42
|
+
|
|
43
|
+
const DOC = 'spec/v2/core/identity.md §2.1 (RFC 0200 §D)';
|
|
44
|
+
const ID = 'openwop.requirement.0200.id-token-aud';
|
|
45
|
+
|
|
46
|
+
let server: Server | null = null;
|
|
47
|
+
let issuer: SyntheticOIDCIssuer | null = null;
|
|
48
|
+
afterAll(async () => {
|
|
49
|
+
if (server !== null) { const s = server; server = null; await new Promise<void>((r) => s.close(() => r())); }
|
|
50
|
+
issuer = null;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** Stand the issuer up on the URL the operator told the host to trust. */
|
|
54
|
+
async function harness(audience: string): Promise<{ url: string; issuer: SyntheticOIDCIssuer } | null> {
|
|
55
|
+
const url = process.env['OPENWOP_TEST_OIDC_ISSUER_URL']?.trim();
|
|
56
|
+
if (!url) return null;
|
|
57
|
+
if (issuer !== null) return { url, issuer };
|
|
58
|
+
const made = createSyntheticOIDCIssuer({ issuer: url, audience, algorithm: 'RS256' });
|
|
59
|
+
const parsed = new URL(url);
|
|
60
|
+
const srv = createServer((r, res) => {
|
|
61
|
+
if (r.url === '/.well-known/jwks.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.jwksJson); return; }
|
|
62
|
+
if (r.url === '/.well-known/openid-configuration') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(made.discoveryJson); return; }
|
|
63
|
+
res.writeHead(404); res.end();
|
|
64
|
+
});
|
|
65
|
+
await new Promise<void>((resolve, reject) => {
|
|
66
|
+
srv.once('error', reject);
|
|
67
|
+
srv.listen(parsed.port ? Number.parseInt(parsed.port, 10) : 80, '127.0.0.1', () => resolve());
|
|
68
|
+
});
|
|
69
|
+
server = srv;
|
|
70
|
+
issuer = made;
|
|
71
|
+
return { url, issuer: made };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
describe('RFC 0200 §D — v2-oidc-id-token-audience (gated on an oidc lane and a trusted harness issuer)', () => {
|
|
75
|
+
it('a same-audience ID token is admissible, and one minted for another relying party is audience_mismatch', async () => {
|
|
76
|
+
const g = await prmGate();
|
|
77
|
+
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
78
|
+
const oidc = g.lanes.find((l) => l.lane === 'oidc');
|
|
79
|
+
if (oidc === undefined) return softSkip('inapplicable', 'no oidc lane advertised — §D names the ID-token case ON that lane and binds no other');
|
|
80
|
+
const audience = process.env['OPENWOP_TEST_OIDC_AUDIENCE']?.trim() ?? 'openwop-conformance';
|
|
81
|
+
const h = await harness(audience);
|
|
82
|
+
if (h === null) return softSkip('blocked', 'OPENWOP_TEST_OIDC_ISSUER_URL is not set — without an issuer the host trusts, no minted token could ever be accepted and the admissible half of §D cannot run');
|
|
83
|
+
|
|
84
|
+
// An ID token, not an access token: `nonce` and `auth_time` are what make it one.
|
|
85
|
+
const good = h.issuer.mint({ sub: `conformance-${randomBytes(6).toString('hex')}`, nonce: randomBytes(8).toString('hex'), auth_time: Math.floor(Date.now() / 1000) });
|
|
86
|
+
const probe = await driver.post('/runs', { workflowId: 'conformance-noop' }, { authenticated: false, headers: { Authorization: `Bearer ${good.token}` } });
|
|
87
|
+
// The ADMISSIBLE half of §D is the gate, not an assertion, and deliberately: from
|
|
88
|
+
// outside, "this host does not trust the harness issuer" and "this host refuses ID
|
|
89
|
+
// tokens" are the same observation — a 401. Recording `blocked` says which was not
|
|
90
|
+
// established rather than reporting a configuration gap as a host defect. What the
|
|
91
|
+
// acceptance DOES establish, once it holds, is that the refusal below is caused by the
|
|
92
|
+
// audience and by nothing else: same issuer, same key, same token shape.
|
|
93
|
+
if (probe.status === 401) {
|
|
94
|
+
return softSkip('blocked', `a same-audience ID token from ${h.url} was refused (401 ${readErrorCode(probe.json) ?? ''}) — either the host does not trust the harness issuer as its oidc lane trust root, or OPENWOP_TEST_OIDC_AUDIENCE does not name the audience it is configured with; with no accepted token the foreign-audience refusal would prove nothing`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const foreign = h.issuer.mint({ sub: `conformance-${randomBytes(6).toString('hex')}`, aud: `other-client-${randomBytes(6).toString('hex')}`, nonce: randomBytes(8).toString('hex'), auth_time: Math.floor(Date.now() / 1000) });
|
|
98
|
+
const bad = await driver.post('/runs', { workflowId: 'conformance-noop' }, { authenticated: false, headers: { Authorization: `Bearer ${foreign.token}` } });
|
|
99
|
+
expect(
|
|
100
|
+
bad.status,
|
|
101
|
+
req(ID, DOC, `an ID token minted for ANOTHER relying party at the same IdP MUST be refused 401 — it verifies against the same trust root, so only the audience check stops the substitution (got ${bad.status})`),
|
|
102
|
+
).toBe(401);
|
|
103
|
+
expect(
|
|
104
|
+
readErrorCode(bad.json),
|
|
105
|
+
req(ID, `${DOC}; spec/v2/errors.json`, 'the refusal code MUST be audience_mismatch — the credential verified, its audience is not this host'),
|
|
106
|
+
).toBe('audience_mismatch');
|
|
107
|
+
}, 60_000);
|
|
108
|
+
});
|