Repository navigation
feat(sep-1932): Client check for dpop_jkt #523
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| #!/usr/bin/env node | ||
|
|
||
| import { runDpopClient } from './helpers/dpopClientFlow'; | ||
| import { runAsCli } from './helpers/cliRunner'; | ||
|
|
||
| /** | ||
| * DPoP client that omits `dpop_jkt` on the authorization request. Isolates a | ||
| * WARNING of sep-1932-client-dpop-jkt (SEP-1932 / RFC 9449 §10). | ||
| */ | ||
| export async function runClient(serverUrl: string): Promise<void> { | ||
| await runDpopClient(serverUrl, { | ||
| scheme: 'DPoP', | ||
| freshProofPerRequest: true, | ||
| sendTokenRequestProof: true, | ||
| handleAsNonce: true, | ||
| handleRsNonce: true, | ||
| sendDpopJkt: false | ||
| }); | ||
| } | ||
|
|
||
| runAsCli(runClient, import.meta.url, 'auth-test-dpop-no-jkt <server-url>'); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| #!/usr/bin/env node | ||
|
|
||
| import { runDpopClient } from './helpers/dpopClientFlow'; | ||
| import { runAsCli } from './helpers/cliRunner'; | ||
|
|
||
| /** | ||
| * Broken DPoP client: sends a `dpop_jkt` that does not match the DPoP proof | ||
| * key used at the token endpoint. Isolates a FAILURE of | ||
| * sep-1932-client-dpop-jkt; the test AS rejects the token request with 400 | ||
| * `invalid_grant` (RFC 9449 §10). | ||
| */ | ||
| export async function runClient(serverUrl: string): Promise<void> { | ||
| await runDpopClient(serverUrl, { | ||
| scheme: 'DPoP', | ||
| freshProofPerRequest: true, | ||
| sendTokenRequestProof: true, | ||
| handleAsNonce: true, | ||
| handleRsNonce: true, | ||
| wrongDpopJkt: true | ||
| }); | ||
| } | ||
|
|
||
| runAsCli(runClient, import.meta.url, 'auth-test-dpop-wrong-jkt <server-url>'); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,7 @@ import { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } from './helpers/dpopResourceAuth'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { SpecReferences } from './spec-references'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { collapseDuplicateChecks } from '../../../checks/collapse'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { notTestable } from '../../untestable'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const PRM_PATH = '/.well-known/oauth-protected-resource/mcp'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -76,9 +77,25 @@ const CHECK_DEFS: Record< | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SpecReferences.DPOP_EXTENSION, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SpecReferences.RFC_9449_RS_NONCE | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'sep-1932-client-dpop-jkt': { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: 'DpopAuthCodeBinding', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| description: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Client binds the authorization code to its DPoP key via the dpop_jkt authorization request parameter (RFC 9449 §10)', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| specReferences: [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SpecReferences.SEP_1932_DPOP, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SpecReferences.DPOP_EXTENSION, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| SpecReferences.RFC_9449_DPOP_JKT | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * SEP-1932 requires the client to bind the authorization code to its DPoP key | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * with `dpop_jkt` (RFC 9449 §10). Omission is a SHOULD miss. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const DPOP_JKT_NOT_SENT_STATUS: CheckStatus = 'WARNING'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Scenario: DPoP sender-constrained tokens — MCP client (SEP-1932 / RFC 9449). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -91,15 +108,16 @@ const CHECK_DEFS: Record< | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * - `auth/dpop` (`requireNonce = false`) — the common, nonce-less baseline. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Neither the AS nor the MCP server issues a nonce challenge; the client | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * completes the flow with plain proofs. Emits three checks: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * completes the flow with plain proofs. Emits four checks: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * · token acquisition — a valid DPoP proof at the token request, obtaining | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * a sender-constrained token (RFC 9449 §5); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * · the authorization code is bound to the DPoP key via `dpop_jkt` (§10); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * · the token is presented with the `DPoP` Authorization scheme (§7.1); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * · a fresh, well-formed DPoP proof accompanies each request (unique `jti`). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * - `auth/dpop-nonce` (`requireNonce = true`) — the AS and MCP server both | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * require a server-provided nonce (§8/§9), exercising the client's nonce | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * handling. Emits the three baseline checks plus two more: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * handling. Emits the four baseline checks plus two more: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * · the client retries the token request with the AS-supplied nonce (§8); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * · the client retries the MCP request with the server-supplied nonce (§9). | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -108,7 +126,8 @@ function newTokenReqObs(): DpopTokenRequestObservation { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| recorded: false, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| validProof: false, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| asNonceChallengeIssued: false, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| asNonceHonored: false | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| asNonceHonored: false, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dpopJktMatched: false | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -185,6 +204,7 @@ export class DPoPClientScenario implements Scenario { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const checks: ConformanceCheck[] = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...shared, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.tokenRequestProofCheck(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.dpopJktCheck(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.authSchemeCheck(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.freshProofCheck() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -244,6 +264,39 @@ export class DPoPClientScenario implements Scenario { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private dpopJktCheck(): ConformanceCheck { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const sent = this.tokenReqObs.dpopJktSent; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const matched = this.tokenReqObs.dpopJktMatched; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let status: CheckStatus; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let errorMessage: string | undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let untestable = false; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!this.tokenReqObs.recorded || !this.tokenReqObs.validProof) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| status = 'WARNING'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| untestable = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| errorMessage = notTestable( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'the client did not present a valid token-endpoint DPoP proof, so dpop_jkt key agreement could not be evaluated' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (sent !== undefined && matched) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| status = 'SUCCESS'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else if (sent !== undefined) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| status = 'FAILURE'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| errorMessage = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Client sent dpop_jkt but it does not match the DPoP proof key used at the token endpoint'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| status = DPOP_JKT_NOT_SENT_STATUS; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| errorMessage = | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Client did not send dpop_jkt on the authorization request to bind the authorization code to its DPoP key'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return this.build('sep-1932-client-dpop-jkt', status, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| errorMessage, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| details: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dpopJktSent: sent, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| dpopJktMatched: matched, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...(untestable ? { untestable: true } : {}) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+272
to
+295
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This untestable emission drops the
Suggested change
Verified against the PR head with this change applied: tsc --noEmit clean, prettier clean, index.test.ts 70/70. Generated with the aid of Claude (Fable 5). |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private tokenRequestProofCheck(): ConformanceCheck { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let status: CheckStatus; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| let errorMessage: string | undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import type { ConformanceCheck } from '../../../../types'; | ||
| import { testScenarioContext } from '../../../../mock-server/testing'; | ||
| import { | ||
| generateDpopKeyPair, | ||
| buildDpopProof, | ||
| type DpopKeyPair | ||
| } from './dpopProof'; | ||
| import { | ||
| createAuthServer, | ||
| type DpopTokenRequestObservation | ||
| } from './createAuthServer'; | ||
| import { ServerLifecycle } from './serverLifecycle'; | ||
|
|
||
| const REDIRECT = 'http://127.0.0.1:9876/callback'; | ||
|
|
||
| function newObs(): DpopTokenRequestObservation { | ||
| return { | ||
| recorded: false, | ||
| validProof: false, | ||
| asNonceChallengeIssued: false, | ||
| asNonceHonored: false, | ||
| dpopJktMatched: false | ||
| }; | ||
| } | ||
|
|
||
| async function requestAuthorizationCode( | ||
| base: string, | ||
| keyPair: DpopKeyPair | ||
| ): Promise<string> { | ||
| const authorizeUrl = `${base}/authorize?${new URLSearchParams({ | ||
| response_type: 'code', | ||
| client_id: 'test', | ||
| redirect_uri: REDIRECT, | ||
| code_challenge: 'x', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These tests run the AS through a deliberately broken PKCE exchange. 'x'/'x' is not a valid S256 pair ('x' is also well under RFC 7636 §4.1's 43-char verifier minimum), so every run of this file records a pkce-verifier-matches-challenge FAILURE into the checks array as a side effect (verified with a probe). The tests still pass because this test AS observes PKCE rather than enforcing it — but that makes the fixture silently dependent on non-enforcement, and it means the §10 behavior under test is being exercised in a run that's simultaneously failing an unrelated conformance check. A real pair costs a few lines (both helpers pick it up; can't put this in one suggestion block since it touches the imports, a new constant, and line 64): import { createHash } from 'node:crypto';
// A real S256 pair (RFC 7636 §4.1: verifier 43-128 chars), so these tests
// don't depend on the test AS tolerating an invalid PKCE exchange.
const CODE_VERIFIER = 'dpop-jkt-tests-code-verifier-0123456789-0123456789a';
const CODE_CHALLENGE = createHash('sha256')
.update(CODE_VERIFIER)
.digest('base64url');…then Generated with the aid of Claude (Fable 5). |
||
| code_challenge_method: 'S256', | ||
| dpop_jkt: keyPair.thumbprint | ||
| }).toString()}`; | ||
| const response = await fetch(authorizeUrl, { redirect: 'manual' }); | ||
| return new URL(response.headers.get('location')!).searchParams.get('code')!; | ||
| } | ||
|
|
||
| async function exchangeAuthorizationCode( | ||
| base: string, | ||
| code: string, | ||
| keyPair: DpopKeyPair | ||
| ): Promise<Response> { | ||
| const tokenEndpoint = `${base}/token`; | ||
| const proof = await buildDpopProof({ | ||
| keyPair, | ||
| htm: 'POST', | ||
| htu: tokenEndpoint | ||
| }); | ||
| return fetch(tokenEndpoint, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'content-type': 'application/x-www-form-urlencoded', | ||
| dpop: proof | ||
| }, | ||
| body: new URLSearchParams({ | ||
| grant_type: 'authorization_code', | ||
| code, | ||
| redirect_uri: REDIRECT, | ||
| code_verifier: 'x', | ||
| client_id: 'test' | ||
| }) | ||
| }); | ||
| } | ||
|
|
||
| describe('createAuthServer — RFC 9449 §10 dpop_jkt binding', () => { | ||
| it('rejects authorization_code with 400 invalid_grant when dpop_jkt does not match the proof key', async () => { | ||
| const checks: ConformanceCheck[] = []; | ||
| const lifecycle = new ServerLifecycle(); | ||
| const obs = newObs(); | ||
| const app = createAuthServer( | ||
| testScenarioContext(), | ||
| checks, | ||
| lifecycle.getUrl, | ||
| { | ||
| dpopSigningAlgValuesSupported: ['ES256'], | ||
| dpopTokenRequestObs: obs, | ||
| loggingEnabled: false | ||
| } | ||
| ); | ||
| await lifecycle.start(app); | ||
| try { | ||
| const proofKp = await generateDpopKeyPair(); | ||
| const otherKp = await generateDpopKeyPair(); | ||
| const code = await requestAuthorizationCode(lifecycle.getUrl(), otherKp); | ||
| const tokenRes = await exchangeAuthorizationCode( | ||
| lifecycle.getUrl(), | ||
| code, | ||
| proofKp | ||
| ); | ||
|
|
||
| expect(tokenRes.status).toBe(400); | ||
| const body = (await tokenRes.json()) as { | ||
| error?: string; | ||
| error_description?: string; | ||
| }; | ||
| expect(body.error).toBe('invalid_grant'); | ||
| expect(body.error_description).toBe( | ||
| 'dpop_jkt does not match the DPoP proof key' | ||
| ); | ||
| expect(obs.dpopJktSent).toBe(otherKp.thumbprint); | ||
| expect(obs.dpopJktMatched).toBe(false); | ||
| expect(obs.validProof).toBe(true); | ||
| } finally { | ||
| await lifecycle.stop(); | ||
| } | ||
| }); | ||
|
|
||
| it('binds dpop_jkt to each authorization code across overlapping flows', async () => { | ||
| const checks: ConformanceCheck[] = []; | ||
| const lifecycle = new ServerLifecycle(); | ||
| const obs = newObs(); | ||
| const app = createAuthServer( | ||
| testScenarioContext(), | ||
| checks, | ||
| lifecycle.getUrl, | ||
| { | ||
| dpopSigningAlgValuesSupported: ['ES256'], | ||
| dpopTokenRequestObs: obs, | ||
| loggingEnabled: false | ||
| } | ||
| ); | ||
| await lifecycle.start(app); | ||
| try { | ||
| const first = await generateDpopKeyPair(); | ||
| const second = await generateDpopKeyPair(); | ||
| const firstCode = await requestAuthorizationCode( | ||
| lifecycle.getUrl(), | ||
| first | ||
| ); | ||
| const secondCode = await requestAuthorizationCode( | ||
| lifecycle.getUrl(), | ||
| second | ||
| ); | ||
|
|
||
| for (const [code, keyPair] of [ | ||
| [firstCode, first], | ||
| [secondCode, second] | ||
| ] as const) { | ||
| const response = await exchangeAuthorizationCode( | ||
| lifecycle.getUrl(), | ||
| code, | ||
| keyPair | ||
| ); | ||
| expect(response.status).toBe(200); | ||
| expect(obs.dpopJktSent).toBe(keyPair.thumbprint); | ||
| expect(obs.dpopJktMatched).toBe(true); | ||
| } | ||
| } finally { | ||
| await lifecycle.stop(); | ||
| } | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good news first: per the conversation thread, the severity below is staying exactly as you built it — SEP-1932 is adopting a SHOULD for authorization code binding (spec text in modelcontextprotocol/ext-auth#35), SHOULD maps to WARNING under the suite's convention, so
DPOP_JKT_NOT_SENT_STATUSis correct as-is.What needs fixing is one word of provenance: "requires" overstates the keyword, and the comment contradicts itself — the next sentence already calls omission "a SHOULD miss." Since the keyword was genuinely contested in the thread, the paper trail should show this check grades a SHOULD, not a MUST.
Generated with the aid of Claude (Fable 5).