Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/scenarios/authorization-server/auth/spec-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,26 @@ export const SpecReferences: { [key: string]: SpecReference } = {
OAUTH_2_1_AUTHORIZATION_CODE_GRANT: {
id: 'OAUTH-2.1-authorization-code-grant',
url: 'https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-13.html#section-4.1'
},
// DPoP (SEP-1932 / RFC 9449) — authorization-server concerns.
SEP_1932_DPOP: {
id: 'SEP-1932-DPoP',
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932'
},
DPOP_EXTENSION: {
id: 'MCP-DPoP-Extension',
url: 'https://github.com/modelcontextprotocol/ext-auth/blob/pieterkas-dpop-extension/specification/draft/dpop-extension.mdx'
},
RFC_9449_AS_METADATA: {
id: 'RFC-9449-authorization-server-metadata',
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-5.1'
},
RFC_9449_PUBLIC_KEY_CONFIRMATION: {
id: 'RFC-9449-public-key-confirmation',
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-6'
},
RFC_9449_ALGORITHMS: {
id: 'RFC-9449-dpop-proof-jwt-syntax',
url: 'https://www.rfc-editor.org/rfc/rfc9449.html#section-11.6'
}
};
171 changes: 171 additions & 0 deletions src/scenarios/authorization-server/dpop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { describe, it, expect } from 'vitest';
import {
createAuthServer,
type AuthServerOptions
} from '../client/auth/helpers/createAuthServer';
import { ServerLifecycle } from '../client/auth/helpers/serverLifecycle';
import { testScenarioContext } from '../../mock-server/testing';
import type { CheckStatus, ConformanceCheck } from '../../types';
import { DPoPAuthorizationServerScenario, negotiateProofAlg } from './dpop';

const ALL_IDS = [
'sep-1932-as-metadata-alg-values',
'sep-1932-as-no-none-alg',
'sep-1932-as-token-binding'
] as const;

const statusOf = (
checks: ConformanceCheck[],
id: string
): CheckStatus | undefined => checks.find((c) => c.id === id)?.status;

/**
* Start an in-process test AS (real Express app, no mocks) with the given DPoP
* options, run the scenario against its live URL, and return the emitted checks.
* The AS 302s straight to the redirect_uri, so the scenario auto-follows headless.
*/
async function runAgainst(
dpopOptions: Partial<AuthServerOptions>,
// `false` means "send no client_id" — a plain `undefined` would re-trigger the
// default via JS default-parameter semantics.
clientId: string | false = 'test-client-id'
): Promise<ConformanceCheck[]> {
const lifecycle = new ServerLifecycle();
const app = createAuthServer(testScenarioContext(), [], lifecycle.getUrl, {
loggingEnabled: false,
grantTypesSupported: ['authorization_code', 'refresh_token'],
...dpopOptions
});
await lifecycle.start(app);
try {
return await new DPoPAuthorizationServerScenario().run(
{ url: lifecycle.getUrl(), port: 45678, clientId: clientId || undefined },
{}
);
} finally {
await lifecycle.stop();
}
}

// A DPoP-capable AS: advertises an asymmetric alg and issues bound tokens.
// (`dpop_bound_access_tokens` is per-client registration metadata, RFC 9449
// §5.2 — not an AS option — so it is deliberately not set here.)
const COMPLIANT: Partial<AuthServerOptions> = {
dpopSigningAlgValuesSupported: ['ES256']
};

describe('DPoPAuthorizationServerScenario — compliant AS', () => {
it('emits all three sep-1932-as-* checks as SUCCESS', async () => {
const checks = await runAgainst(COMPLIANT);
for (const id of ALL_IDS) {
expect(statusOf(checks, id)).toBe('SUCCESS');
}
expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0);
});

it('binds the issued token to the presented proof key (cnf.jkt matches)', async () => {
const checks = await runAgainst(COMPLIANT);
const binding = checks.find((c) => c.id === 'sep-1932-as-token-binding');
expect(binding?.status).toBe('SUCCESS');
const details = binding?.details as {
tokenType: string;
cnfJkt: string;
expectedJkt: string;
};
expect(details.tokenType).toBe('DPoP');
expect(details.cnfJkt).toBe(details.expectedJkt);
});
});

// Isolation matrix: each defect fails EXACTLY its target check, the rest stay
// SUCCESS. (`omit-alg-values` is not here — dropping the field means "not a DPoP
// AS", which SKIPs the whole scenario; see the support-gate tests below.)
describe('DPoPAuthorizationServerScenario — one-defect isolation', () => {
const CASES = [
{
misbehavior: 'empty-alg-values',
target: 'sep-1932-as-metadata-alg-values'
},
{ misbehavior: 'include-none', target: 'sep-1932-as-no-none-alg' },
{ misbehavior: 'unbound-token', target: 'sep-1932-as-token-binding' }
] as const;

for (const { misbehavior, target } of CASES) {
it(`misbehaving AS (${misbehavior}) fails only ${target}`, async () => {
const checks = await runAgainst({
...COMPLIANT,
dpopMisbehavior: misbehavior
});
expect(statusOf(checks, target)).toBe('FAILURE');
for (const id of ALL_IDS.filter((c) => c !== target)) {
expect(statusOf(checks, id)).toBe('SUCCESS');
}
});
}

it('fails the no-none-alg check when a symmetric algorithm is advertised', async () => {
const checks = await runAgainst({
dpopSigningAlgValuesSupported: ['ES256', 'HS256']
});
expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS');
expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('FAILURE');
});
});

describe('DPoPAuthorizationServerScenario — skip conditions', () => {
it('skips the token-binding check when no client_id is supplied', async () => {
const checks = await runAgainst(COMPLIANT, false);
expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS');
expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('SUCCESS');
expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SKIPPED');
});

it('skips token binding when no advertised proof alg is supported (no ES256 fallback)', async () => {
// ES256K is asymmetric (passes no-none-alg) but not one the harness can
// produce; the scenario must SKIP rather than send an unadvertised ES256
// proof the AS would reject and mis-score as a binding failure.
const checks = await runAgainst({
dpopSigningAlgValuesSupported: ['ES256K']
});
expect(statusOf(checks, 'sep-1932-as-metadata-alg-values')).toBe('SUCCESS');
expect(statusOf(checks, 'sep-1932-as-no-none-alg')).toBe('SUCCESS');
expect(statusOf(checks, 'sep-1932-as-token-binding')).toBe('SKIPPED');
});

it('skips the whole scenario when the AS does not advertise DPoP support', async () => {
// No dpop_signing_alg_values_supported → not a DPoP AS (RFC 9449 §5.1), so
// the DPoP requirements do not apply: every check SKIPs rather than fails.
const checks = await runAgainst({ dpopMisbehavior: 'omit-alg-values' });
for (const id of ALL_IDS) {
expect(statusOf(checks, id)).toBe('SKIPPED');
}
expect(checks.filter((c) => c.status === 'FAILURE')).toHaveLength(0);
});
});

describe('negotiateProofAlg (dpop_signing_alg_values_supported shapes)', () => {
it('picks the first supported alg from a non-empty array', () => {
expect(negotiateProofAlg(['ES256'])).toBe('ES256');
expect(negotiateProofAlg(['RS256', 'ES256'])).toBe('RS256');
});

it('returns null for a non-empty array with no supported alg (→ SKIP)', () => {
expect(negotiateProofAlg(['ES256K'])).toBeNull();
});

it('falls back to ES256 only for an empty array or an absent field', () => {
expect(negotiateProofAlg([])).toBe('ES256');
// Absent never reaches here in the scenario (the support gate SKIPs upstream),
// but the contract still treats undefined as the empty/best-effort case.
expect(negotiateProofAlg(undefined)).toBe('ES256');
});

it('returns null for a present-but-non-array (malformed) value (→ SKIP)', () => {
// Regression guard: a string or JSON null must NOT fall through to the
// ES256 fallback, which would mis-score token binding.
expect(negotiateProofAlg('RS256')).toBeNull();
expect(negotiateProofAlg(null)).toBeNull();
expect(negotiateProofAlg(42)).toBeNull();
expect(negotiateProofAlg({ 0: 'ES256' })).toBeNull();
});
});
Loading