Skip to content

Commit ad465f9

Browse files
authored
Merge branch 'main' into paulc/bump-0.2.0-alpha.12
2 parents f44482b + 0e95350 commit ad465f9

7 files changed

Lines changed: 1072 additions & 4 deletions

File tree

‎src/scenarios/authorization-server/auth/spec-references.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,26 @@ export const SpecReferences: { [key: string]: SpecReference } = {
88
OAUTH_2_1_AUTHORIZATION_CODE_GRANT: {
99
id: 'OAUTH-2.1-authorization-code-grant',
1010
url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.1'
11+
},
12+
// DPoP (SEP-1932 / RFC 9449) — authorization-server concerns.
13+
SEP_1932_DPOP: {
14+
id: 'SEP-1932-DPoP',
15+
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932'
16+
},
17+
DPOP_EXTENSION: {
18+
id: 'MCP-DPoP-Extension',
19+
url: 'https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx'
20+
},
21+
RFC_9449_AS_METADATA: {
22+
id: 'RFC-9449-authorization-server-metadata',
23+
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-5.1'
24+
},
25+
RFC_9449_PUBLIC_KEY_CONFIRMATION: {
26+
id: 'RFC-9449-public-key-confirmation',
27+
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-6'
28+
},
29+
RFC_9449_ALGORITHMS: {
30+
id: 'RFC-9449-dpop-proof-jwt-syntax',
31+
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-11.6'
1132
}
1233
};
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
createAuthServer,
4+
type AuthServerOptions
5+
} from '../client/auth/helpers/createAuthServer';
6+
import { ServerLifecycle } from '../client/auth/helpers/serverLifecycle';
7+
import { testScenarioContext } from '../../mock-server/testing';
8+
import type { CheckStatus, ConformanceCheck } from '../../types';
9+
import { DPoPAuthorizationServerScenario, negotiateProofAlg } from './dpop';
10+
11+
const ALL_IDS = [
12+
'sep-1932-as-metadata-alg-values',
13+
'sep-1932-as-no-none-alg',
14+
'sep-1932-as-token-binding'
15+
] as const;
16+
17+
const statusOf = (
18+
checks: ConformanceCheck[],
19+
id: string
20+
): CheckStatus | undefined => checks.find((c) => c.id === id)?.status;
21+
22+
/**
23+
* Start an in-process test AS (real Express app, no mocks) with the given DPoP
24+
* options, run the scenario against its live URL, and return the emitted checks.
25+
* The AS 302s straight to the redirect_uri, so the scenario auto-follows headless.
26+
*/
27+
async function runAgainst(
28+
dpopOptions: Partial<AuthServerOptions>,
29+
// `false` means "send no client_id" — a plain `undefined` would re-trigger the
30+
// default via JS default-parameter semantics.
31+
clientId: string | false = 'test-client-id'
32+
): Promise<ConformanceCheck[]> {
33+
const lifecycle = new ServerLifecycle();
34+
const app = createAuthServer(testScenarioContext(), [], lifecycle.getUrl, {
35+
loggingEnabled: false,
36+
grantTypesSupported: ['authorization_code', 'refresh_token'],
37+
...dpopOptions
38+
});
39+
await lifecycle.start(app);
40+
try {
41+
return await new DPoPAuthorizationServerScenario().run(
42+
{ url: lifecycle.getUrl(), port: 45678, clientId: clientId || undefined },
43+
{}
44+
);
45+
} finally {
46+
await lifecycle.stop();
47+
}
48+
}
49+
50+
// A DPoP-capable AS: advertises an asymmetric alg and issues bound tokens.
51+
// (`dpop_bound_access_tokens` is per-client registration metadata, RFC 9449
52+
// §5.2 — not an AS option — so it is deliberately not set here.)
53+
const COMPLIANT: Partial<AuthServerOptions> = {
54+
dpopSigningAlgValuesSupported: ['ES256']
55+
};
56+
57+
describe('DPoPAuthorizationServerScenario — compliant AS', () => {
58+
it('emits all three sep-1932-as-* checks as SUCCESS', async () => {
59+
const checks = await runAgainst(COMPLIANT);
60+
for (const id of ALL_IDS) {
61+
expect(statusOf(checks, id)).toBe('SUCCESS');
62+
}
63+
expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0);
64+
});
65+
66+
it('binds the issued token to the presented proof key (cnf.jkt matches)', async () => {
67+
const checks = await runAgainst(COMPLIANT);
68+
const binding = checks.find((c) => c.id === 'sep-1932-as-token-binding');
69+
expect(binding?.status).toBe('SUCCESS');
70+
const details = binding?.details as {
71+
tokenType: string;
72+
cnfJkt: string;
73+
expectedJkt: string;
74+
};
75+
expect(details.tokenType).toBe('DPoP');
76+
expect(details.cnfJkt).toBe(details.expectedJkt);
77+
});
78+
});
79+
80+
// Isolation matrix: each defect fails EXACTLY its target check, the rest stay
81+
// SUCCESS. (`omit-alg-values` is not here — dropping the field means "not a DPoP
82+
// AS", which SKIPs the whole scenario; see the support-gate tests below.)
83+
describe('DPoPAuthorizationServerScenario — one-defect isolation', () => {
84+
const CASES = [
85+
{
86+
misbehavior: 'empty-alg-values',
87+
target: 'sep-1932-as-metadata-alg-values'
88+
},
89+
{ misbehavior: 'include-none', target: 'sep-1932-as-no-none-alg' },
90+
{ misbehavior: 'unbound-token', target: 'sep-1932-as-token-binding' }
91+
] as const;
92+
93+
for (const { misbehavior, target } of CASES) {
94+
it(`misbehaving AS (${misbehavior}) fails only ${target}`, async () => {
95+
const checks = await runAgainst({
96+
...COMPLIANT,
97+
dpopMisbehavior: misbehavior
98+
});
99+
expect(statusOf(checks, target)).toBe('FAILURE');
100+
for (const id of ALL_IDS.filter((c) => c !== target)) {
101+
expect(statusOf(checks, id)).toBe('SUCCESS');
102+
}
103+
});
104+
}
105+
106+
it('fails the no-none-alg check when a symmetric algorithm is advertised', async () => {
107+
const checks = await runAgainst({
108+
dpopSigningAlgValuesSupported: ['ES256', 'HS256']
109+
});
110+
expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS');
111+
expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('FAILURE');
112+
});
113+
});
114+
115+
describe('DPoPAuthorizationServerScenario — skip conditions', () => {
116+
it('skips the token-binding check when no client_id is supplied', async () => {
117+
const checks = await runAgainst(COMPLIANT, false);
118+
expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS');
119+
expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('SUCCESS');
120+
expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SKIPPED');
121+
});
122+
123+
it('skips token binding when no advertised proof alg is supported (no ES256 fallback)', async () => {
124+
// ES256K is asymmetric (passes no-none-alg) but not one the harness can
125+
// produce; the scenario must SKIP rather than send an unadvertised ES256
126+
// proof the AS would reject and mis-score as a binding failure.
127+
const checks = await runAgainst({
128+
dpopSigningAlgValuesSupported: ['ES256K']
129+
});
130+
expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS');
131+
expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('SUCCESS');
132+
expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SKIPPED');
133+
});
134+
135+
it('skips the whole scenario when the AS does not advertise DPoP support', async () => {
136+
// No dpop_signing_alg_values_supported → not a DPoP AS (RFC 9449 §5.1), so
137+
// the DPoP requirements do not apply: every check SKIPs rather than fails.
138+
const checks = await runAgainst({ dpopMisbehavior: 'omit-alg-values' });
139+
for (const id of ALL_IDS) {
140+
expect(statusOf(checks, id)).toBe('SKIPPED');
141+
}
142+
expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0);
143+
});
144+
});
145+
146+
describe('negotiateProofAlg (dpop_signing_alg_values_supported shapes)', () => {
147+
it('picks the first supported alg from a non-empty array', () => {
148+
expect(negotiateProofAlg(['ES256'])).toBe('ES256');
149+
expect(negotiateProofAlg(['RS256', 'ES256'])).toBe('RS256');
150+
});
151+
152+
it('returns null for a non-empty array with no supported alg (→ SKIP)', () => {
153+
expect(negotiateProofAlg(['ES256K'])).toBeNull();
154+
});
155+
156+
it('falls back to ES256 only for an empty array or an absent field', () => {
157+
expect(negotiateProofAlg([])).toBe('ES256');
158+
// Absent never reaches here in the scenario (the support gate SKIPs upstream),
159+
// but the contract still treats undefined as the empty/best-effort case.
160+
expect(negotiateProofAlg(undefined)).toBe('ES256');
161+
});
162+
163+
it('returns null for a present-but-non-array (malformed) value (→ SKIP)', () => {
164+
// Regression guard: a string or JSON null must NOT fall through to the
165+
// ES256 fallback, which would mis-score token binding.
166+
expect(negotiateProofAlg('RS256')).toBeNull();
167+
expect(negotiateProofAlg(null)).toBeNull();
168+
expect(negotiateProofAlg(42)).toBeNull();
169+
expect(negotiateProofAlg({ 0: 'ES256' })).toBeNull();
170+
});
171+
});

0 commit comments

Comments
 (0)