feat(auth): client conformance for Workload Identity Federation (SEP-1933) (#268)
* feat(auth): add JWT-bearer helper for WIF client conformance (SEP-1933) Adds createWorkloadJwt and generateWorkloadKeypair to provide reusable, tested JWT signing infrastructure for the upcoming wif-jwt-bearer scenario (PR #2). Also extracts JWT_BEARER_GRANT_TYPE constant and migrates cross-app-access.ts to use it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): add JWT-bearer helper for WIF client conformance (SEP-1933) Adds createWorkloadJwt and generateWorkloadKeypair to provide reusable, tested JWT signing infrastructure for the upcoming wif-jwt-bearer scenario (PR #2). Also extracts JWT_BEARER_GRANT_TYPE constant and migrates cross-app-access.ts to use it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(auth): add WIF JWT-bearer scenario and negative tests (SEP-1933) Adds the auth/wif-jwt-bearer client conformance scenario using the RFC 7523 JWT-bearer grant (urn:ietf:params:oauth:grant-type:jwt-bearer). The scenario pre-signs valid, wrong-audience, and expired JWTs on start() to simulate cloud workload identity tokens. The conformance AS verifies the assertion and emits per-class checks (wif-assertion-verified, wif-assertion-missing, wif-assertion-audience, wif-assertion-expired, wif-assertion-malformed). Broken example clients exercise the missing-assertion and wrong-audience failure paths. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: apply prettier formatting to wif-jwt-bearer.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(auth): add expired-assertion negative test; clarify wif-jwt-bearer comments - Add runWifJwtBearerExpiredAssertion + auth-test-wif-expired-assertion.ts to exercise the wif-assertion-expired check path (was dead code) - Clarify WifAssertionVerified description to note iss is not validated - Add comment explaining clockTolerance: 5 is intentional (same-run keypair) - Add comment explaining numeric expiresIn is absolute epoch seconds Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wif-jwt-bearer): disable DCR, detect retries, improve getChecks description - Add WIF_CLIENT_ID constant; pre-seed provider's clientInformation() so the SDK skips Dynamic Client Registration entirely (disableDynamicRegistration on the auth server + pre-seeded client_id on the provider side) - Add failedOnce/tokenRequestReceived tracking on the scenario; reject and record wif-no-retry FAILURE if the client attempts a second token request after the first fails - Add hasAttempted guard in WifJwtBearerProvider.prepareTokenRequest() to throw on retry from the client side - Fix getChecks() sentinel description: distinguish "no request made" from "request made but verification failed" - Add client_id to context and Zod schema Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: prettier formatting on everything-client.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wif-jwt-bearer): let errors propagate from broken example clients The try/catch blocks were silently swallowing auth failures, so the negative tests passed purely because expectedFailureSlugs found the AS-emitted check — not because client error-surfacing was verified. allowClientError: true on the test cases handles the non-zero exit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wif-jwt-bearer): tag as draft, add scope-rejected check, add SEP traceability - Change specVersions from ['extension'] to [DRAFT_PROTOCOL_VERSION] and add source = { introducedIn: DRAFT_PROTOCOL_VERSION } so the scenario is reachable via --spec-version draft (extension tag excluded it from all spec-version runs) - Move registration from extensionScenariosList to draftScenariosList in index.ts to match the tag/list convention enforced by spec-version.test.ts - Add wif-assertion-scope-rejected check: AS returns invalid_scope for a valid JWT when the client requests the reserved 'wif.rejected' scope; verify client surfaces the error and does not retry - Add runWifJwtBearerScopeRejected to everything-client.ts; add optional scope param to WifJwtBearerProvider; add CLI entry point and vitest case - Add src/seps/sep-1933.yaml with requirements mapped to each check ID, covering the three deferred checks (iss, sub, jti) with exclusion rationale Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: prettier formatting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(wif-jwt-bearer): add wif-grant-fallback check for unauthorized_client fallback Client bug class: WIF client receives unauthorized_client from JWT-bearer grant and silently switches to authorization_code instead of surfacing the error. The MCP SDK retries after UnauthorizedClientError (auth.js:152-154), calling prepareTokenRequest() a second time. WifGrantFallbackProvider exploits this by returning authorization_code params on the second call. Spec anchor: RFC 7523 §2.1 — clients MUST use the JWT-bearer grant type; silent grant-type switching hides authentication failures. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: rename unused scope param in WifGrantFallbackProvider Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(wif-jwt-bearer): use realistic SPIFFE and K8s PSAT token formats valid_jwt uses a SPIFFE JWT-SVID subject (spiffe://conformance-test.local/...) issued by a SPIRE-style issuer. wrong_audience_jwt uses a Kubernetes PSAT subject and kubernetes.io claims, mirroring how K8s projected service-account tokens look in production. Token verification and client behaviour are unchanged; the formats ground the scenario in real workload identity platforms. k8s_issuer and k8s_subject added to context and schema for external clients that want to construct their own K8s-style assertions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * style: prettier formatting on wif-jwt-bearer.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(wif-jwt-bearer): drop informational k8s_issuer/k8s_subject from context These fields were not used by any client handler and are redundant since the K8s claims are already baked into wrong_audience_jwt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wif-jwt-bearer): address pre-merge review feedback - Downgrade wif-no-retry, wif-grant-fallback, and wif-assertion-scope-rejected to WARNING; RFC 7523 is silent on client retry and grant-type switching, so FAILURE status was not traceable to spec text - Add WifRetryProvider broken client that re-sends JWT-bearer after unauthorized_client, making wif-no-retry actually exercisable as a WARNING - Drop getChecks() sentinel (non-standard pattern that overloaded the wif-assertion-verified check ID); test harness detects empty check sets - Add comment on DRAFT_PROTOCOL_VERSION workaround for runner limitation - Add comment explaining why both slash/no-slash audience forms are accepted - Tighten schema comments for audience URL constraint and ES256 literal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wif-jwt-bearer): drop decorative fields and neutralise JWT fixtures - Remove issuer and subject from context schema and scenario return value; neither is validated by the AS or used by the client, making them misleading. The iss exclusion rationale is updated to explain why. - Replace SPIFFE/K8s-PSAT constants and the kubernetes.io additionalClaims with neutral values across all three JWT fixtures; no check inspects token format, so format flavour was decorative and invited false assumptions about fixture semantics. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wif-jwt-bearer): remove workaround comment, drop signing_algorithm, annotate retry provider - Remove self-admitted workaround comment on DRAFT_PROTOCOL_VERSION; the classification stands on its own and the comment was inviting challenge - Drop signing_algorithm from context schema and scenario return; the field was redundant once the value was fixed to ES256, and the assertion is opaque to the client regardless - Add comment to WifRetryProvider explaining it deliberately omits the hasAttempted guard so the SDK retry reaches the AS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wif-jwt-bearer): export scope constants, remove unused AS field, annotate state machine - Export WIF_TRIGGER_UNAUTHORIZED_SCOPE and WIF_REJECTED_SCOPE from createWorkloadJwt.ts and import them in everything-client.ts; removes duplicate bare-string declarations that could silently diverge - Remove tokenEndpointAuthSigningAlgValuesSupported from mock AS config; the SDK does not consume this field in the JWT-bearer flow - Add comment to onTokenRequest clarifying wif-no-retry and wif-grant-fallback fire on any second request after any failure, not only post-unauthorized_client Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wif-jwt-bearer): address pcarleton review comments - Move all broken WIF provider classes and runners to wif-broken-clients.ts; everything-client.ts now contains only the conformant WifJwtBearerProvider - Add sentinel in getChecks() for !tokenRequestReceived to prevent a silent pass when the client never sends a request - Drop wif-assertion-scope-rejected check; the no-retry behaviour after invalid_scope is already covered by wif-no-retry, and sep-1933.yaml updated accordingly - Remove audience from context schema and scenario return; the field was unused by any client implementation - Expand scenario description to tell a client implementer what to implement - Apply suggested description fix to wif-grant-fallback (does not assume unauthorized_client was the specific error that preceded the fallback) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(wif-jwt-bearer): drop number type from expiresIn, use jose duration strings Jose supports negative duration strings ('-60s', '60 seconds ago') that produce already-expired tokens without needing absolute epoch arithmetic. Drop the number overload, update the expired_jwt call site to '-60s', and update the unit test accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
794dcab99e
commit
1ca3bc301b
17 changed files with 1229 additions and 4 deletions
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Broken WIF client: presents a JWT that is already expired.
|
||||
* BUG: Uses expired_jwt instead of valid_jwt — server rejects with invalid_grant.
|
||||
*/
|
||||
|
||||
import { runWifJwtBearerExpiredAssertion } from './wif-broken-clients.js';
|
||||
import { runAsCli } from './helpers/cliRunner.js';
|
||||
|
||||
runAsCli(
|
||||
runWifJwtBearerExpiredAssertion,
|
||||
import.meta.url,
|
||||
'auth-test-wif-expired-assertion <server-url>'
|
||||
);
|
||||
15
examples/clients/typescript/auth-test-wif-grant-fallback.ts
Normal file
15
examples/clients/typescript/auth-test-wif-grant-fallback.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Broken WIF client: falls back to authorization_code after receiving unauthorized_client.
|
||||
* BUG: switches grant type instead of surfacing the error.
|
||||
*/
|
||||
|
||||
import { runWifJwtBearerGrantFallback } from './wif-broken-clients.js';
|
||||
import { runAsCli } from './helpers/cliRunner.js';
|
||||
|
||||
runAsCli(
|
||||
runWifJwtBearerGrantFallback,
|
||||
import.meta.url,
|
||||
'auth-test-wif-grant-fallback <server-url>'
|
||||
);
|
||||
15
examples/clients/typescript/auth-test-wif-no-assertion.ts
Normal file
15
examples/clients/typescript/auth-test-wif-no-assertion.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Broken WIF client: omits the assertion parameter from the token request.
|
||||
* BUG: Does not include assertion in JWT-bearer grant — server rejects with invalid_request.
|
||||
*/
|
||||
|
||||
import { runWifJwtBearerMissingAssertion } from './wif-broken-clients.js';
|
||||
import { runAsCli } from './helpers/cliRunner.js';
|
||||
|
||||
runAsCli(
|
||||
runWifJwtBearerMissingAssertion,
|
||||
import.meta.url,
|
||||
'auth-test-wif-no-assertion <server-url>'
|
||||
);
|
||||
15
examples/clients/typescript/auth-test-wif-retry.ts
Normal file
15
examples/clients/typescript/auth-test-wif-retry.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Broken WIF client: retries JWT-bearer after receiving unauthorized_client.
|
||||
* BUG: retries instead of surfacing the error.
|
||||
*/
|
||||
|
||||
import { runWifJwtBearerRetry } from './wif-broken-clients.js';
|
||||
import { runAsCli } from './helpers/cliRunner.js';
|
||||
|
||||
runAsCli(
|
||||
runWifJwtBearerRetry,
|
||||
import.meta.url,
|
||||
'auth-test-wif-retry <server-url>'
|
||||
);
|
||||
15
examples/clients/typescript/auth-test-wif-scope-rejected.ts
Normal file
15
examples/clients/typescript/auth-test-wif-scope-rejected.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Broken WIF client: requests a scope the AS does not permit for JWT-bearer grant.
|
||||
* BUG: Includes 'wif.rejected' in the scope parameter — AS returns invalid_scope.
|
||||
*/
|
||||
|
||||
import { runWifJwtBearerScopeRejected } from './wif-broken-clients.js';
|
||||
import { runAsCli } from './helpers/cliRunner.js';
|
||||
|
||||
runAsCli(
|
||||
runWifJwtBearerScopeRejected,
|
||||
import.meta.url,
|
||||
'auth-test-wif-scope-rejected <server-url>'
|
||||
);
|
||||
15
examples/clients/typescript/auth-test-wif-wrong-audience.ts
Normal file
15
examples/clients/typescript/auth-test-wif-wrong-audience.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Broken WIF client: presents a JWT with the wrong audience.
|
||||
* BUG: Uses wrong_audience_jwt instead of valid_jwt — server rejects with invalid_grant.
|
||||
*/
|
||||
|
||||
import { runWifJwtBearerWrongAudience } from './wif-broken-clients.js';
|
||||
import { runAsCli } from './helpers/cliRunner.js';
|
||||
|
||||
runAsCli(
|
||||
runWifJwtBearerWrongAudience,
|
||||
import.meta.url,
|
||||
'auth-test-wif-wrong-audience <server-url>'
|
||||
);
|
||||
|
|
@ -19,6 +19,13 @@ import {
|
|||
ClientCredentialsProvider,
|
||||
PrivateKeyJwtProvider
|
||||
} from '@modelcontextprotocol/sdk/client/auth-extensions.js';
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { JWT_BEARER_GRANT_TYPE } from '../../../src/scenarios/client/auth/helpers/createWorkloadJwt.js';
|
||||
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { ClientConformanceContextSchema } from '../../../src/schemas/context.js';
|
||||
import { DRAFT_PROTOCOL_VERSION } from '../../../src/types.js';
|
||||
|
|
@ -990,6 +997,107 @@ async function runMRTRClient(serverUrl: string): Promise<void> {
|
|||
|
||||
registerScenario('sep-2322-client-request-state', runMRTRClient);
|
||||
|
||||
// ============================================================================
|
||||
// WIF JWT-bearer scenario
|
||||
// ============================================================================
|
||||
|
||||
class WifJwtBearerProvider implements OAuthClientProvider {
|
||||
private _tokens?: OAuthTokens;
|
||||
private _clientInfo: OAuthClientInformation;
|
||||
private readonly _clientMetadata: OAuthClientMetadata;
|
||||
private hasAttempted = false;
|
||||
|
||||
// Pass null for assertion to deliberately omit it (missing-assertion negative tests).
|
||||
constructor(
|
||||
private readonly assertion: string | null,
|
||||
clientId: string,
|
||||
private readonly scope?: string
|
||||
) {
|
||||
this._clientInfo = { client_id: clientId };
|
||||
this._clientMetadata = {
|
||||
client_name: 'conformance-wif-jwt-bearer',
|
||||
redirect_uris: [],
|
||||
grant_types: [JWT_BEARER_GRANT_TYPE],
|
||||
token_endpoint_auth_method: 'none'
|
||||
};
|
||||
}
|
||||
|
||||
get redirectUrl(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return this._clientMetadata;
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformation {
|
||||
return this._clientInfo;
|
||||
}
|
||||
|
||||
saveClientInformation(info: OAuthClientInformation): void {
|
||||
this._clientInfo = info;
|
||||
}
|
||||
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return this._tokens;
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens): void {
|
||||
this._tokens = tokens;
|
||||
}
|
||||
|
||||
redirectToAuthorization(): void {
|
||||
throw new Error('redirectToAuthorization is not used for JWT-bearer flow');
|
||||
}
|
||||
|
||||
saveCodeVerifier(): void {}
|
||||
|
||||
codeVerifier(): string {
|
||||
throw new Error('codeVerifier is not used for JWT-bearer flow');
|
||||
}
|
||||
|
||||
prepareTokenRequest(scope?: string): URLSearchParams {
|
||||
if (this.hasAttempted) {
|
||||
throw new Error('JWT-bearer grant must not be retried after failure');
|
||||
}
|
||||
this.hasAttempted = true;
|
||||
const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });
|
||||
if (this.assertion !== null) params.set('assertion', this.assertion);
|
||||
const effectiveScope = this.scope ?? scope;
|
||||
if (effectiveScope) params.set('scope', effectiveScope);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWifJwtBearer(serverUrl: string): Promise<void> {
|
||||
const ctx = parseContext();
|
||||
if (ctx.name !== 'auth/wif-jwt-bearer') {
|
||||
throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`);
|
||||
}
|
||||
|
||||
const provider = new WifJwtBearerProvider(ctx.valid_jwt, ctx.client_id);
|
||||
|
||||
const client = new Client(
|
||||
{ name: 'conformance-wif-jwt-bearer', version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
authProvider: provider
|
||||
});
|
||||
|
||||
await client.connect(transport);
|
||||
logger.debug('Successfully connected with JWT-bearer assertion');
|
||||
|
||||
await client.listTools();
|
||||
logger.debug('Successfully listed tools');
|
||||
|
||||
await transport.close();
|
||||
logger.debug('Connection closed successfully');
|
||||
}
|
||||
|
||||
registerScenario('auth/wif-jwt-bearer', runWifJwtBearer);
|
||||
|
||||
// ============================================================================
|
||||
// Main entry point
|
||||
// ============================================================================
|
||||
|
|
|
|||
303
examples/clients/typescript/wif-broken-clients.ts
Normal file
303
examples/clients/typescript/wif-broken-clients.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Broken WIF client implementations for negative conformance tests.
|
||||
*
|
||||
* Each class/runner exercises a specific non-compliant behaviour.
|
||||
* WifJwtBearerProvider (the well-behaved client) lives in everything-client.ts.
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthClientMetadata,
|
||||
OAuthTokens
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import {
|
||||
JWT_BEARER_GRANT_TYPE,
|
||||
WIF_TRIGGER_UNAUTHORIZED_SCOPE,
|
||||
WIF_REJECTED_SCOPE
|
||||
} from '../../../src/scenarios/client/auth/helpers/createWorkloadJwt.js';
|
||||
import { ClientConformanceContextSchema } from '../../../src/schemas/context.js';
|
||||
|
||||
function parseWifContext() {
|
||||
const raw = process.env.MCP_CONFORMANCE_CONTEXT;
|
||||
if (!raw) throw new Error('MCP_CONFORMANCE_CONTEXT not set');
|
||||
const ctx = ClientConformanceContextSchema.parse(JSON.parse(raw));
|
||||
if (ctx.name !== 'auth/wif-jwt-bearer') {
|
||||
throw new Error(`Expected wif-jwt-bearer context, got ${ctx.name}`);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Base class with OAuthClientProvider boilerplate shared across all broken variants.
|
||||
abstract class WifProviderBase implements OAuthClientProvider {
|
||||
private _tokens?: OAuthTokens;
|
||||
protected _clientInfo: OAuthClientInformation;
|
||||
protected readonly _clientMetadata: OAuthClientMetadata;
|
||||
|
||||
constructor(clientId: string, clientName: string) {
|
||||
this._clientInfo = { client_id: clientId };
|
||||
this._clientMetadata = {
|
||||
client_name: clientName,
|
||||
redirect_uris: [],
|
||||
grant_types: [JWT_BEARER_GRANT_TYPE],
|
||||
token_endpoint_auth_method: 'none'
|
||||
};
|
||||
}
|
||||
|
||||
get redirectUrl(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
return this._clientMetadata;
|
||||
}
|
||||
|
||||
clientInformation(): OAuthClientInformation {
|
||||
return this._clientInfo;
|
||||
}
|
||||
|
||||
saveClientInformation(info: OAuthClientInformation): void {
|
||||
this._clientInfo = info;
|
||||
}
|
||||
|
||||
tokens(): OAuthTokens | undefined {
|
||||
return this._tokens;
|
||||
}
|
||||
|
||||
saveTokens(tokens: OAuthTokens): void {
|
||||
this._tokens = tokens;
|
||||
}
|
||||
|
||||
redirectToAuthorization(): void {
|
||||
throw new Error('redirectToAuthorization is not used for JWT-bearer flow');
|
||||
}
|
||||
|
||||
saveCodeVerifier(): void {}
|
||||
|
||||
codeVerifier(): string {
|
||||
throw new Error('codeVerifier is not used for JWT-bearer flow');
|
||||
}
|
||||
|
||||
abstract prepareTokenRequest(_scope?: string): URLSearchParams;
|
||||
}
|
||||
|
||||
async function runWifBrokenClient(
|
||||
serverUrl: string,
|
||||
provider: WifProviderBase,
|
||||
clientName: string
|
||||
): Promise<void> {
|
||||
const client = new Client(
|
||||
{ name: clientName, version: '1.0.0' },
|
||||
{ capabilities: {} }
|
||||
);
|
||||
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
||||
authProvider: provider
|
||||
});
|
||||
await client.connect(transport);
|
||||
await client.listTools();
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wrong audience
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BUG: presents a JWT whose aud does not match the AS
|
||||
class WifWrongAudienceProvider extends WifProviderBase {
|
||||
constructor(
|
||||
private readonly assertion: string,
|
||||
clientId: string
|
||||
) {
|
||||
super(clientId, 'conformance-wif-wrong-audience');
|
||||
}
|
||||
|
||||
prepareTokenRequest(_scope?: string): URLSearchParams {
|
||||
const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });
|
||||
params.set('assertion', this.assertion);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWifJwtBearerWrongAudience(
|
||||
serverUrl: string
|
||||
): Promise<void> {
|
||||
const ctx = parseWifContext();
|
||||
await runWifBrokenClient(
|
||||
serverUrl,
|
||||
new WifWrongAudienceProvider(ctx.wrong_audience_jwt, ctx.client_id),
|
||||
'conformance-wif-wrong-audience'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Missing assertion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BUG: omits the assertion parameter entirely
|
||||
class WifMissingAssertionProvider extends WifProviderBase {
|
||||
constructor(clientId: string) {
|
||||
super(clientId, 'conformance-wif-no-assertion');
|
||||
}
|
||||
|
||||
prepareTokenRequest(_scope?: string): URLSearchParams {
|
||||
return new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWifJwtBearerMissingAssertion(
|
||||
serverUrl: string
|
||||
): Promise<void> {
|
||||
const ctx = parseWifContext();
|
||||
await runWifBrokenClient(
|
||||
serverUrl,
|
||||
new WifMissingAssertionProvider(ctx.client_id),
|
||||
'conformance-wif-no-assertion'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expired assertion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BUG: presents a JWT whose exp has already passed
|
||||
class WifExpiredAssertionProvider extends WifProviderBase {
|
||||
constructor(
|
||||
private readonly assertion: string,
|
||||
clientId: string
|
||||
) {
|
||||
super(clientId, 'conformance-wif-expired-assertion');
|
||||
}
|
||||
|
||||
prepareTokenRequest(_scope?: string): URLSearchParams {
|
||||
const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });
|
||||
params.set('assertion', this.assertion);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWifJwtBearerExpiredAssertion(
|
||||
serverUrl: string
|
||||
): Promise<void> {
|
||||
const ctx = parseWifContext();
|
||||
await runWifBrokenClient(
|
||||
serverUrl,
|
||||
new WifExpiredAssertionProvider(ctx.expired_jwt, ctx.client_id),
|
||||
'conformance-wif-expired-assertion'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope rejected
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BUG: requests a scope the AS does not permit for JWT-bearer grant
|
||||
class WifScopeRejectedProvider extends WifProviderBase {
|
||||
constructor(
|
||||
private readonly assertion: string,
|
||||
clientId: string
|
||||
) {
|
||||
super(clientId, 'conformance-wif-scope-rejected');
|
||||
}
|
||||
|
||||
prepareTokenRequest(_scope?: string): URLSearchParams {
|
||||
const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });
|
||||
params.set('assertion', this.assertion);
|
||||
params.set('scope', WIF_REJECTED_SCOPE);
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWifJwtBearerScopeRejected(
|
||||
serverUrl: string
|
||||
): Promise<void> {
|
||||
const ctx = parseWifContext();
|
||||
await runWifBrokenClient(
|
||||
serverUrl,
|
||||
new WifScopeRejectedProvider(ctx.valid_jwt, ctx.client_id),
|
||||
'conformance-wif-scope-rejected'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Grant fallback
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BUG: falls back to authorization_code after receiving unauthorized_client
|
||||
class WifGrantFallbackProvider extends WifProviderBase {
|
||||
private attemptCount = 0;
|
||||
|
||||
constructor(
|
||||
private readonly assertion: string,
|
||||
clientId: string
|
||||
) {
|
||||
super(clientId, 'conformance-wif-grant-fallback');
|
||||
}
|
||||
|
||||
prepareTokenRequest(_scope?: string): URLSearchParams {
|
||||
this.attemptCount++;
|
||||
if (this.attemptCount === 1) {
|
||||
const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });
|
||||
params.set('assertion', this.assertion);
|
||||
params.set('scope', WIF_TRIGGER_UNAUTHORIZED_SCOPE);
|
||||
return params;
|
||||
}
|
||||
// BUG: switches to authorization_code instead of surfacing the error
|
||||
return new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: 'fake-fallback-code'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWifJwtBearerGrantFallback(
|
||||
serverUrl: string
|
||||
): Promise<void> {
|
||||
const ctx = parseWifContext();
|
||||
await runWifBrokenClient(
|
||||
serverUrl,
|
||||
new WifGrantFallbackProvider(ctx.valid_jwt, ctx.client_id),
|
||||
'conformance-wif-grant-fallback'
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Retry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BUG: retries JWT-bearer after receiving unauthorized_client.
|
||||
// Deliberately omits a hasAttempted guard so the SDK retry reaches the AS
|
||||
// and the wif-no-retry check fires.
|
||||
class WifRetryProvider extends WifProviderBase {
|
||||
private attemptCount = 0;
|
||||
|
||||
constructor(
|
||||
private readonly assertion: string,
|
||||
clientId: string
|
||||
) {
|
||||
super(clientId, 'conformance-wif-retry');
|
||||
}
|
||||
|
||||
prepareTokenRequest(_scope?: string): URLSearchParams {
|
||||
this.attemptCount++;
|
||||
const params = new URLSearchParams({ grant_type: JWT_BEARER_GRANT_TYPE });
|
||||
params.set('assertion', this.assertion);
|
||||
if (this.attemptCount === 1) {
|
||||
params.set('scope', WIF_TRIGGER_UNAUTHORIZED_SCOPE);
|
||||
}
|
||||
// BUG: retries JWT-bearer instead of surfacing the error
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runWifJwtBearerRetry(serverUrl: string): Promise<void> {
|
||||
const ctx = parseWifContext();
|
||||
await runWifBrokenClient(
|
||||
serverUrl,
|
||||
new WifRetryProvider(ctx.valid_jwt, ctx.client_id),
|
||||
'conformance-wif-retry'
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type { CryptoKey } from 'jose';
|
|||
import express, { type Request, type Response } from 'express';
|
||||
import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types';
|
||||
import { createAuthServer } from './helpers/createAuthServer';
|
||||
import { JWT_BEARER_GRANT_TYPE } from './helpers/createWorkloadJwt.js';
|
||||
import { createServer } from './helpers/createServer';
|
||||
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
|
||||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||
|
|
@ -88,7 +89,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario {
|
|||
// Start auth server with JWT bearer grant support only
|
||||
// Token exchange is handled by IdP
|
||||
const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, {
|
||||
grantTypesSupported: ['urn:ietf:params:oauth:grant-type:jwt-bearer'],
|
||||
grantTypesSupported: [JWT_BEARER_GRANT_TYPE],
|
||||
tokenEndpointAuthMethodsSupported: ['client_secret_basic'],
|
||||
tokenVerifier,
|
||||
onTokenRequest: async ({
|
||||
|
|
@ -99,7 +100,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario {
|
|||
authorizationHeader
|
||||
}) => {
|
||||
// Auth server only handles JWT bearer grant (ID-JAG -> access token)
|
||||
if (grantType === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
|
||||
if (grantType === JWT_BEARER_GRANT_TYPE) {
|
||||
const mcpResourceUrl = `${this.mcpServer.getUrl()}/mcp`;
|
||||
return await this.handleJwtBearerGrant(
|
||||
body,
|
||||
|
|
|
|||
194
src/scenarios/client/auth/helpers/createWorkloadJwt.test.ts
Normal file
194
src/scenarios/client/auth/helpers/createWorkloadJwt.test.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import * as jose from 'jose';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
JWT_BEARER_GRANT_TYPE,
|
||||
DEFAULT_WORKLOAD_JWT_ALG,
|
||||
createWorkloadJwt,
|
||||
generateWorkloadKeypair
|
||||
} from './createWorkloadJwt.js';
|
||||
|
||||
describe('constants', () => {
|
||||
it('JWT_BEARER_GRANT_TYPE matches the IANA-registered URN format', () => {
|
||||
expect(JWT_BEARER_GRANT_TYPE).toMatch(/^urn:ietf:params:oauth:grant-type:/);
|
||||
expect(JWT_BEARER_GRANT_TYPE).toBe(
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer'
|
||||
);
|
||||
});
|
||||
|
||||
it('DEFAULT_WORKLOAD_JWT_ALG is ES256', () => {
|
||||
expect(DEFAULT_WORKLOAD_JWT_ALG).toBe('ES256');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateWorkloadKeypair', () => {
|
||||
it('returns an ES256 keypair with PEM and JWK', async () => {
|
||||
const kp = await generateWorkloadKeypair();
|
||||
expect(kp.privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/);
|
||||
expect(kp.publicJwk.kty).toBe('EC');
|
||||
expect(kp.publicJwk.crv).toBe('P-256');
|
||||
expect(kp.publicKey).toBeDefined();
|
||||
expect(kp.privateKey).toBeDefined();
|
||||
});
|
||||
|
||||
it('uses the specified algorithm', async () => {
|
||||
const kp = await generateWorkloadKeypair('RS256');
|
||||
expect(kp.publicJwk.kty).toBe('RSA');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createWorkloadJwt', () => {
|
||||
it('produces a verifiable JWT with all standard claims', async () => {
|
||||
const kp = await generateWorkloadKeypair();
|
||||
const token = await createWorkloadJwt({
|
||||
issuer: 'https://issuer.example',
|
||||
subject: 'system:serviceaccount:prod:my-app',
|
||||
audience: 'https://as.example/token',
|
||||
privateKey: kp.privateKey
|
||||
});
|
||||
|
||||
const { payload } = await jose.jwtVerify(token, kp.publicKey, {
|
||||
issuer: 'https://issuer.example',
|
||||
audience: 'https://as.example/token'
|
||||
});
|
||||
|
||||
expect(payload.iss).toBe('https://issuer.example');
|
||||
expect(payload.sub).toBe('system:serviceaccount:prod:my-app');
|
||||
expect(payload.aud).toBe('https://as.example/token');
|
||||
expect(typeof payload.exp).toBe('number');
|
||||
expect(typeof payload.iat).toBe('number');
|
||||
expect(typeof payload.jti).toBe('string');
|
||||
});
|
||||
|
||||
it('defaults exp to approximately 5 minutes after iat', async () => {
|
||||
const kp = await generateWorkloadKeypair();
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const token = await createWorkloadJwt({
|
||||
issuer: 'https://issuer.example',
|
||||
subject: 'workload',
|
||||
audience: 'https://as.example/token',
|
||||
privateKey: kp.privateKey
|
||||
});
|
||||
const after = Math.floor(Date.now() / 1000);
|
||||
|
||||
const { payload } = await jose.jwtVerify(token, kp.publicKey);
|
||||
const lifetime = (payload.exp as number) - (payload.iat as number);
|
||||
expect(lifetime).toBeGreaterThanOrEqual(5 * 60 - 2);
|
||||
expect(lifetime).toBeLessThanOrEqual(5 * 60 + 2);
|
||||
expect(payload.iat as number).toBeGreaterThanOrEqual(before);
|
||||
expect(payload.iat as number).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it('accepts a negative duration string as expiresIn for already-expired tokens', async () => {
|
||||
const kp = await generateWorkloadKeypair();
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const token = await createWorkloadJwt({
|
||||
issuer: 'https://issuer.example',
|
||||
subject: 'workload',
|
||||
audience: 'https://as.example/token',
|
||||
privateKey: kp.privateKey,
|
||||
expiresIn: '-60s'
|
||||
});
|
||||
|
||||
const payload = jose.decodeJwt(token);
|
||||
expect(payload.exp).toBeLessThan(before);
|
||||
await expect(jose.jwtVerify(token, kp.publicKey)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('generates a unique jti on each call with identical inputs', async () => {
|
||||
const kp = await generateWorkloadKeypair();
|
||||
const opts = {
|
||||
issuer: 'https://issuer.example',
|
||||
subject: 'workload',
|
||||
audience: 'https://as.example/token',
|
||||
privateKey: kp.privateKey
|
||||
};
|
||||
const t1 = await createWorkloadJwt(opts);
|
||||
const t2 = await createWorkloadJwt(opts);
|
||||
|
||||
const { payload: p1 } = await jose.jwtVerify(t1, kp.publicKey);
|
||||
const { payload: p2 } = await jose.jwtVerify(t2, kp.publicKey);
|
||||
expect(p1.jti).not.toBe(p2.jti);
|
||||
});
|
||||
|
||||
it('preserves an array audience as an array', async () => {
|
||||
const kp = await generateWorkloadKeypair();
|
||||
const token = await createWorkloadJwt({
|
||||
issuer: 'https://issuer.example',
|
||||
subject: 'workload',
|
||||
audience: ['https://as.example/token', 'https://other.example'],
|
||||
privateKey: kp.privateKey
|
||||
});
|
||||
|
||||
const { payload } = await jose.jwtVerify(token, kp.publicKey, {
|
||||
audience: 'https://as.example/token'
|
||||
});
|
||||
expect(Array.isArray(payload.aud)).toBe(true);
|
||||
expect(payload.aud).toContain('https://as.example/token');
|
||||
expect(payload.aud).toContain('https://other.example');
|
||||
});
|
||||
|
||||
it('merges additionalClaims without overriding reserved claims', async () => {
|
||||
const kp = await generateWorkloadKeypair();
|
||||
const token = await createWorkloadJwt({
|
||||
issuer: 'https://issuer.example',
|
||||
subject: 'workload',
|
||||
audience: 'https://as.example/token',
|
||||
privateKey: kp.privateKey,
|
||||
additionalClaims: {
|
||||
custom: 'value',
|
||||
iss: 'should-be-ignored',
|
||||
sub: 'should-be-ignored'
|
||||
}
|
||||
});
|
||||
|
||||
const { payload } = await jose.jwtVerify(token, kp.publicKey);
|
||||
expect(payload.custom).toBe('value');
|
||||
expect(payload.iss).toBe('https://issuer.example');
|
||||
expect(payload.sub).toBe('workload');
|
||||
});
|
||||
|
||||
it('allows caller-supplied jwtId', async () => {
|
||||
const kp = await generateWorkloadKeypair();
|
||||
const token = await createWorkloadJwt({
|
||||
issuer: 'https://issuer.example',
|
||||
subject: 'workload',
|
||||
audience: 'https://as.example/token',
|
||||
privateKey: kp.privateKey,
|
||||
jwtId: 'fixed-jti-for-replay-test'
|
||||
});
|
||||
|
||||
const { payload } = await jose.jwtVerify(token, kp.publicKey);
|
||||
expect(payload.jti).toBe('fixed-jti-for-replay-test');
|
||||
});
|
||||
|
||||
it('sets notBefore when specified', async () => {
|
||||
const kp = await generateWorkloadKeypair();
|
||||
const nbf = Math.floor(Date.now() / 1000) + 3600;
|
||||
const token = await createWorkloadJwt({
|
||||
issuer: 'https://issuer.example',
|
||||
subject: 'workload',
|
||||
audience: 'https://as.example/token',
|
||||
privateKey: kp.privateKey,
|
||||
notBefore: nbf
|
||||
});
|
||||
|
||||
const payload = jose.decodeJwt(token);
|
||||
expect(payload.nbf).toBe(nbf);
|
||||
});
|
||||
|
||||
it('uses the specified algorithm when signing', async () => {
|
||||
const kp = await generateWorkloadKeypair('RS256');
|
||||
const token = await createWorkloadJwt({
|
||||
issuer: 'https://issuer.example',
|
||||
subject: 'workload',
|
||||
audience: 'https://as.example/token',
|
||||
privateKey: kp.privateKey,
|
||||
algorithm: 'RS256'
|
||||
});
|
||||
|
||||
const { payload } = await jose.jwtVerify(token, kp.publicKey, {
|
||||
algorithms: ['RS256']
|
||||
});
|
||||
expect(payload.iss).toBe('https://issuer.example');
|
||||
});
|
||||
});
|
||||
86
src/scenarios/client/auth/helpers/createWorkloadJwt.ts
Normal file
86
src/scenarios/client/auth/helpers/createWorkloadJwt.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import * as jose from 'jose';
|
||||
|
||||
export const JWT_BEARER_GRANT_TYPE =
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer';
|
||||
export const DEFAULT_WORKLOAD_JWT_ALG = 'ES256';
|
||||
|
||||
// Scope values used by the WIF scenario's mock AS and broken-client runners.
|
||||
// Exported here so both sides stay in sync without manual duplication.
|
||||
export const WIF_TRIGGER_UNAUTHORIZED_SCOPE = 'wif.trigger-unauthorized';
|
||||
export const WIF_REJECTED_SCOPE = 'wif.rejected';
|
||||
|
||||
export interface CreateWorkloadJwtOptions {
|
||||
issuer: string;
|
||||
subject: string;
|
||||
audience: string | string[];
|
||||
privateKey: jose.CryptoKey;
|
||||
/** Jose duration string (e.g. '5m', '-60s', '60 seconds ago'). Use a negative offset to construct already-expired tokens for negative tests. */
|
||||
expiresIn?: string;
|
||||
jwtId?: string;
|
||||
issuedAt?: number;
|
||||
notBefore?: number;
|
||||
algorithm?: string;
|
||||
additionalClaims?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function createWorkloadJwt(
|
||||
opts: CreateWorkloadJwtOptions
|
||||
): Promise<string> {
|
||||
const {
|
||||
issuer,
|
||||
subject,
|
||||
audience,
|
||||
privateKey,
|
||||
expiresIn = '5m',
|
||||
jwtId = crypto.randomUUID(),
|
||||
issuedAt,
|
||||
notBefore,
|
||||
algorithm = DEFAULT_WORKLOAD_JWT_ALG,
|
||||
additionalClaims
|
||||
} = opts;
|
||||
|
||||
// additionalClaims are merged first; reserved claims set via builder methods
|
||||
// overwrite any same-named key already in the payload, so callers cannot
|
||||
// accidentally override iss/sub/aud/exp/iat/jti via additionalClaims.
|
||||
const extra: Record<string, unknown> = additionalClaims
|
||||
? { ...additionalClaims }
|
||||
: {};
|
||||
|
||||
let builder = new jose.SignJWT(extra)
|
||||
.setProtectedHeader({ alg: algorithm })
|
||||
.setIssuer(issuer)
|
||||
.setSubject(subject)
|
||||
.setAudience(audience)
|
||||
.setExpirationTime(expiresIn)
|
||||
.setJti(jwtId);
|
||||
|
||||
if (issuedAt !== undefined) {
|
||||
builder = builder.setIssuedAt(issuedAt);
|
||||
} else {
|
||||
builder = builder.setIssuedAt();
|
||||
}
|
||||
|
||||
if (notBefore !== undefined) {
|
||||
builder = builder.setNotBefore(notBefore);
|
||||
}
|
||||
|
||||
return builder.sign(privateKey);
|
||||
}
|
||||
|
||||
export interface WorkloadKeypair {
|
||||
publicKey: jose.CryptoKey;
|
||||
privateKey: jose.CryptoKey;
|
||||
privateKeyPem: string;
|
||||
publicJwk: jose.JWK;
|
||||
}
|
||||
|
||||
export async function generateWorkloadKeypair(
|
||||
alg: string = DEFAULT_WORKLOAD_JWT_ALG
|
||||
): Promise<WorkloadKeypair> {
|
||||
const { publicKey, privateKey } = await jose.generateKeyPair(alg, {
|
||||
extractable: true
|
||||
});
|
||||
const privateKeyPem = await jose.exportPKCS8(privateKey);
|
||||
const publicJwk = await jose.exportJWK(publicKey);
|
||||
return { publicKey, privateKey, privateKeyPem, publicJwk };
|
||||
}
|
||||
|
|
@ -1,13 +1,22 @@
|
|||
import {
|
||||
authScenariosList,
|
||||
backcompatScenariosList,
|
||||
draftScenariosList
|
||||
draftScenariosList,
|
||||
extensionScenariosList
|
||||
} from './index';
|
||||
import {
|
||||
runClientAgainstScenario,
|
||||
InlineClientRunner
|
||||
} from './test_helpers/testClient';
|
||||
import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm';
|
||||
import {
|
||||
runWifJwtBearerWrongAudience,
|
||||
runWifJwtBearerMissingAssertion,
|
||||
runWifJwtBearerExpiredAssertion,
|
||||
runWifJwtBearerScopeRejected,
|
||||
runWifJwtBearerGrantFallback,
|
||||
runWifJwtBearerRetry
|
||||
} from '../../../../examples/clients/typescript/wif-broken-clients';
|
||||
import { runClient as noCimdClient } from '../../../../examples/clients/typescript/auth-test-no-cimd';
|
||||
import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope';
|
||||
import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes';
|
||||
|
|
@ -265,3 +274,68 @@ describe('Negative tests', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Extension Scenarios', () => {
|
||||
for (const scenario of extensionScenariosList) {
|
||||
test(`${scenario.name} passes`, async () => {
|
||||
const clientFn = getHandler(scenario.name);
|
||||
if (!clientFn) {
|
||||
throw new Error(`No handler registered for scenario: ${scenario.name}`);
|
||||
}
|
||||
const runner = new InlineClientRunner(clientFn);
|
||||
await runClientAgainstScenario(runner, scenario.name);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// allowClientError: true because broken clients receive an error response from
|
||||
// the AS and will throw. The AS-side check is the authoritative conformance
|
||||
// signal; client process exit behaviour is not asserted here.
|
||||
describe('WIF JWT-bearer negative tests', () => {
|
||||
test('client presents JWT with wrong audience', async () => {
|
||||
const runner = new InlineClientRunner(runWifJwtBearerWrongAudience);
|
||||
await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', {
|
||||
expectedFailureSlugs: ['wif-assertion-audience'],
|
||||
allowClientError: true
|
||||
});
|
||||
});
|
||||
|
||||
test('client omits assertion from JWT-bearer request', async () => {
|
||||
const runner = new InlineClientRunner(runWifJwtBearerMissingAssertion);
|
||||
await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', {
|
||||
expectedFailureSlugs: ['wif-assertion-missing'],
|
||||
allowClientError: true
|
||||
});
|
||||
});
|
||||
|
||||
test('client presents expired JWT assertion', async () => {
|
||||
const runner = new InlineClientRunner(runWifJwtBearerExpiredAssertion);
|
||||
await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', {
|
||||
expectedFailureSlugs: ['wif-assertion-expired'],
|
||||
allowClientError: true
|
||||
});
|
||||
});
|
||||
|
||||
test('client requests a scope the AS rejects for JWT-bearer grant', async () => {
|
||||
const runner = new InlineClientRunner(runWifJwtBearerScopeRejected);
|
||||
await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', {
|
||||
allowClientError: true
|
||||
});
|
||||
});
|
||||
|
||||
test('client falls back to authorization_code after unauthorized_client', async () => {
|
||||
const runner = new InlineClientRunner(runWifJwtBearerGrantFallback);
|
||||
await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', {
|
||||
expectedFailureSlugs: ['wif-grant-fallback'],
|
||||
allowClientError: true
|
||||
});
|
||||
});
|
||||
|
||||
test('client retries JWT-bearer after unauthorized_client', async () => {
|
||||
const runner = new InlineClientRunner(runWifJwtBearerRetry);
|
||||
await runClientAgainstScenario(runner, 'auth/wif-jwt-bearer', {
|
||||
expectedFailureSlugs: ['wif-no-retry'],
|
||||
allowClientError: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
import { ResourceMismatchScenario } from './resource-mismatch';
|
||||
import { PreRegistrationScenario } from './pre-registration';
|
||||
import { EnterpriseManagedAuthorizationScenario } from './enterprise-managed-authorization';
|
||||
import { WifJwtBearerScenario } from './wif-jwt-bearer';
|
||||
import {
|
||||
OfflineAccessScopeScenario,
|
||||
OfflineAccessNotSupportedScenario
|
||||
|
|
@ -79,5 +80,6 @@ export const draftScenariosList: Scenario[] = [
|
|||
new IssParameterWrongIssuerScenario(),
|
||||
new IssParameterUnexpectedScenario(),
|
||||
new IssParameterNormalizedVariantScenario(),
|
||||
new MetadataIssuerMismatchScenario()
|
||||
new MetadataIssuerMismatchScenario(),
|
||||
new WifJwtBearerScenario()
|
||||
];
|
||||
|
|
|
|||
|
|
@ -109,5 +109,9 @@ export const SpecReferences: { [key: string]: SpecReference } = {
|
|||
SEP_2207_REFRESH_TOKEN_GUIDANCE: {
|
||||
id: 'SEP-2207-Refresh-Token-Guidance',
|
||||
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207'
|
||||
},
|
||||
SEP_1933_WIF: {
|
||||
id: 'SEP-1933-Workload-Identity-Federation',
|
||||
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933'
|
||||
}
|
||||
};
|
||||
|
|
|
|||
321
src/scenarios/client/auth/wif-jwt-bearer.ts
Normal file
321
src/scenarios/client/auth/wif-jwt-bearer.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import * as jose from 'jose';
|
||||
import type {
|
||||
Scenario,
|
||||
ConformanceCheck,
|
||||
ScenarioUrls,
|
||||
SpecVersion
|
||||
} from '../../../types';
|
||||
import type { ScenarioContext } from '../../../mock-server';
|
||||
import { DRAFT_PROTOCOL_VERSION } from '../../../types';
|
||||
import { createAuthServer } from './helpers/createAuthServer';
|
||||
import { createServer } from './helpers/createServer';
|
||||
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
|
||||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||
import { SpecReferences } from './spec-references';
|
||||
import {
|
||||
JWT_BEARER_GRANT_TYPE,
|
||||
WIF_TRIGGER_UNAUTHORIZED_SCOPE,
|
||||
WIF_REJECTED_SCOPE,
|
||||
generateWorkloadKeypair,
|
||||
createWorkloadJwt
|
||||
} from './helpers/createWorkloadJwt.js';
|
||||
|
||||
const WIF_ISSUER = 'https://idp.conformance-test.local';
|
||||
const WIF_SUBJECT = 'conformance-workload';
|
||||
const WIF_CLIENT_ID = 'conformance-wif-workload';
|
||||
|
||||
export class WifJwtBearerScenario implements Scenario {
|
||||
name = 'auth/wif-jwt-bearer';
|
||||
specVersions: SpecVersion[] = [DRAFT_PROTOCOL_VERSION];
|
||||
readonly source = { introducedIn: DRAFT_PROTOCOL_VERSION } as const;
|
||||
description =
|
||||
'Tests the RFC 7523 JWT-bearer grant for workload identity federation (SEP-1933). ' +
|
||||
'The client must: use grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, ' +
|
||||
'include the workload JWT as the assertion parameter, and surface errors ' +
|
||||
'(invalid_grant, invalid_scope, unauthorized_client) without retrying or switching grant types.';
|
||||
|
||||
private authServer = new ServerLifecycle();
|
||||
private server = new ServerLifecycle();
|
||||
private checks: ConformanceCheck[] = [];
|
||||
private tokenRequestReceived = false;
|
||||
private failedOnce = false;
|
||||
|
||||
async start(ctx: ScenarioContext): Promise<ScenarioUrls> {
|
||||
this.checks = [];
|
||||
this.tokenRequestReceived = false;
|
||||
this.failedOnce = false;
|
||||
|
||||
const { publicKey, privateKey } = await generateWorkloadKeypair();
|
||||
|
||||
const tokenVerifier = new MockTokenVerifier(this.checks);
|
||||
|
||||
const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, {
|
||||
grantTypesSupported: [JWT_BEARER_GRANT_TYPE],
|
||||
tokenEndpointAuthMethodsSupported: ['none'],
|
||||
tokenVerifier,
|
||||
disableDynamicRegistration: true,
|
||||
onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => {
|
||||
// wif-no-retry and wif-grant-fallback fire on any second request after
|
||||
// any first failure, not only after unauthorized_client specifically.
|
||||
if (this.tokenRequestReceived && this.failedOnce) {
|
||||
if (grantType !== JWT_BEARER_GRANT_TYPE) {
|
||||
this.checks.push({
|
||||
id: 'wif-grant-fallback',
|
||||
name: 'WifGrantFallback',
|
||||
description: `Client fell back to ${grantType} grant after a JWT-bearer token request was rejected; clients should not switch grant types after a JWT-bearer failure`,
|
||||
status: 'WARNING',
|
||||
timestamp,
|
||||
specReferences: [
|
||||
SpecReferences.RFC_7523_JWT_BEARER,
|
||||
SpecReferences.SEP_1933_WIF
|
||||
]
|
||||
});
|
||||
return {
|
||||
error: 'unsupported_grant_type',
|
||||
errorDescription: 'Only JWT-bearer grant is supported'
|
||||
};
|
||||
}
|
||||
this.checks.push({
|
||||
id: 'wif-no-retry',
|
||||
name: 'WifNoRetry',
|
||||
description:
|
||||
'Client retried JWT-bearer token request after a failure instead of giving up',
|
||||
status: 'WARNING',
|
||||
timestamp,
|
||||
specReferences: [
|
||||
SpecReferences.RFC_7523_JWT_BEARER,
|
||||
SpecReferences.SEP_1933_WIF
|
||||
]
|
||||
});
|
||||
return {
|
||||
error: 'invalid_request',
|
||||
errorDescription: 'Retry not allowed for JWT-bearer grant'
|
||||
};
|
||||
}
|
||||
this.tokenRequestReceived = true;
|
||||
if (grantType !== JWT_BEARER_GRANT_TYPE) {
|
||||
this.checks.push({
|
||||
id: 'wif-grant-type',
|
||||
name: 'WifGrantType',
|
||||
description: `Expected grant_type=${JWT_BEARER_GRANT_TYPE}, got ${grantType}`,
|
||||
status: 'FAILURE',
|
||||
timestamp,
|
||||
specReferences: [
|
||||
SpecReferences.RFC_7523_JWT_BEARER,
|
||||
SpecReferences.SEP_1933_WIF
|
||||
]
|
||||
});
|
||||
this.failedOnce = true;
|
||||
return {
|
||||
error: 'unsupported_grant_type',
|
||||
errorDescription: `Only ${JWT_BEARER_GRANT_TYPE} grant is supported`
|
||||
};
|
||||
}
|
||||
|
||||
const assertion = body.assertion;
|
||||
if (!assertion) {
|
||||
this.checks.push({
|
||||
id: 'wif-assertion-missing',
|
||||
name: 'WifAssertionMissing',
|
||||
description:
|
||||
'Missing assertion parameter in JWT-bearer token request',
|
||||
status: 'FAILURE',
|
||||
timestamp,
|
||||
specReferences: [
|
||||
SpecReferences.RFC_7523_JWT_BEARER,
|
||||
SpecReferences.SEP_1933_WIF
|
||||
]
|
||||
});
|
||||
this.failedOnce = true;
|
||||
return {
|
||||
error: 'invalid_request',
|
||||
errorDescription: 'Missing assertion parameter'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const withoutSlash = authBaseUrl.replace(/\/+$/, '');
|
||||
const withSlash = `${withoutSlash}/`;
|
||||
// iss is not validated: the keypair is generated per start() call and
|
||||
// the public key closure binds the assertion to this run. This scenario
|
||||
// tests client behaviour, not AS issuer policy.
|
||||
// clockTolerance of 5s is sufficient because JWTs are signed and consumed
|
||||
// within the same test run; skew from a real IdP is not a factor here.
|
||||
// Both slash forms are accepted because the SDK constructs the audience
|
||||
// from the AS metadata URL, which may or may not carry a trailing slash
|
||||
// depending on how the metadata endpoint was discovered.
|
||||
await jose.jwtVerify(assertion, publicKey, {
|
||||
audience: [withoutSlash, withSlash],
|
||||
clockTolerance: 5
|
||||
});
|
||||
|
||||
this.checks.push({
|
||||
id: 'wif-assertion-verified',
|
||||
name: 'WifAssertionVerified',
|
||||
description:
|
||||
'Workload JWT assertion verified — signature, audience, and expiry are valid (iss not validated; keypair is run-scoped)',
|
||||
status: 'SUCCESS',
|
||||
timestamp,
|
||||
specReferences: [
|
||||
SpecReferences.RFC_7523_JWT_BEARER,
|
||||
SpecReferences.SEP_1933_WIF
|
||||
]
|
||||
});
|
||||
|
||||
const scopeList = body.scope ? body.scope.split(' ') : [];
|
||||
if (scopeList.includes(WIF_TRIGGER_UNAUTHORIZED_SCOPE)) {
|
||||
this.failedOnce = true;
|
||||
return {
|
||||
error: 'unauthorized_client',
|
||||
errorDescription: 'Client not authorized for JWT-bearer grant'
|
||||
};
|
||||
}
|
||||
if (scopeList.includes(WIF_REJECTED_SCOPE)) {
|
||||
this.failedOnce = true;
|
||||
return {
|
||||
error: 'invalid_scope',
|
||||
errorDescription:
|
||||
'Requested scope is not permitted for this grant'
|
||||
};
|
||||
}
|
||||
return {
|
||||
token: `test-token-${Date.now()}`,
|
||||
scopes: scopeList
|
||||
};
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
|
||||
if (e instanceof jose.errors.JWTExpired) {
|
||||
this.checks.push({
|
||||
id: 'wif-assertion-expired',
|
||||
name: 'WifAssertionExpired',
|
||||
description: `JWT-bearer assertion is expired: ${msg}`,
|
||||
status: 'FAILURE',
|
||||
timestamp,
|
||||
specReferences: [
|
||||
SpecReferences.RFC_7523_JWT_BEARER,
|
||||
SpecReferences.SEP_1933_WIF
|
||||
]
|
||||
});
|
||||
this.failedOnce = true;
|
||||
return {
|
||||
error: 'invalid_grant',
|
||||
errorDescription: 'JWT assertion is expired'
|
||||
};
|
||||
}
|
||||
|
||||
// JWTExpired extends JWTClaimValidationFailed; check aud specifically so
|
||||
// other claim failures (iss, nbf, etc.) fall through to malformed.
|
||||
if (
|
||||
e instanceof jose.errors.JWTClaimValidationFailed &&
|
||||
e.claim === 'aud'
|
||||
) {
|
||||
this.checks.push({
|
||||
id: 'wif-assertion-audience',
|
||||
name: 'WifAssertionAudience',
|
||||
description: `JWT-bearer assertion audience claim is invalid: ${msg}`,
|
||||
status: 'FAILURE',
|
||||
timestamp,
|
||||
specReferences: [
|
||||
SpecReferences.RFC_7523_JWT_BEARER,
|
||||
SpecReferences.SEP_1933_WIF
|
||||
]
|
||||
});
|
||||
this.failedOnce = true;
|
||||
return {
|
||||
error: 'invalid_grant',
|
||||
errorDescription: 'JWT assertion audience is invalid'
|
||||
};
|
||||
}
|
||||
|
||||
this.checks.push({
|
||||
id: 'wif-assertion-malformed',
|
||||
name: 'WifAssertionMalformed',
|
||||
description: `JWT-bearer assertion is malformed or has an invalid signature: ${msg}`,
|
||||
status: 'FAILURE',
|
||||
timestamp,
|
||||
specReferences: [
|
||||
SpecReferences.RFC_7523_JWT_BEARER,
|
||||
SpecReferences.SEP_1933_WIF
|
||||
]
|
||||
});
|
||||
this.failedOnce = true;
|
||||
return {
|
||||
error: 'invalid_grant',
|
||||
errorDescription: `JWT assertion verification failed: ${msg}`
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await this.authServer.start(authApp);
|
||||
|
||||
const authServerUrl = this.authServer.getUrl();
|
||||
|
||||
const [validJwt, wrongAudienceJwt, expiredJwt] = await Promise.all([
|
||||
createWorkloadJwt({
|
||||
issuer: WIF_ISSUER,
|
||||
subject: WIF_SUBJECT,
|
||||
audience: authServerUrl,
|
||||
privateKey
|
||||
}),
|
||||
createWorkloadJwt({
|
||||
issuer: WIF_ISSUER,
|
||||
subject: WIF_SUBJECT,
|
||||
audience: 'https://wrong.example',
|
||||
privateKey
|
||||
}),
|
||||
createWorkloadJwt({
|
||||
issuer: WIF_ISSUER,
|
||||
subject: WIF_SUBJECT,
|
||||
audience: authServerUrl,
|
||||
privateKey,
|
||||
// Absolute epoch seconds in the past; jose treats a number as an absolute
|
||||
// epoch timestamp, producing a token that is already expired.
|
||||
expiresIn: '-60s'
|
||||
})
|
||||
]);
|
||||
|
||||
const app = createServer(
|
||||
ctx,
|
||||
this.checks,
|
||||
this.server.getUrl,
|
||||
this.authServer.getUrl,
|
||||
{ tokenVerifier }
|
||||
);
|
||||
|
||||
await this.server.start(app);
|
||||
|
||||
return {
|
||||
serverUrl: `${this.server.getUrl()}/mcp`,
|
||||
context: {
|
||||
client_id: WIF_CLIENT_ID,
|
||||
valid_jwt: validJwt,
|
||||
wrong_audience_jwt: wrongAudienceJwt,
|
||||
expired_jwt: expiredJwt
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.authServer.stop();
|
||||
await this.server.stop();
|
||||
}
|
||||
|
||||
getChecks(): ConformanceCheck[] {
|
||||
if (!this.tokenRequestReceived) {
|
||||
this.checks.push({
|
||||
id: 'wif-assertion-verified',
|
||||
name: 'WifAssertionVerified',
|
||||
description: 'Client did not make a JWT-bearer token request',
|
||||
status: 'FAILURE',
|
||||
timestamp: new Date().toISOString(),
|
||||
specReferences: [
|
||||
SpecReferences.RFC_7523_JWT_BEARER,
|
||||
SpecReferences.SEP_1933_WIF
|
||||
]
|
||||
});
|
||||
}
|
||||
return this.checks;
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,13 @@ export const ClientConformanceContextSchema = z.discriminatedUnion('name', [
|
|||
idp_id_token: z.string(),
|
||||
idp_issuer: z.string(),
|
||||
idp_token_endpoint: z.string()
|
||||
}),
|
||||
z.object({
|
||||
name: z.literal('auth/wif-jwt-bearer'),
|
||||
client_id: z.string(),
|
||||
valid_jwt: z.string(),
|
||||
wrong_audience_jwt: z.string(),
|
||||
expired_jwt: z.string()
|
||||
})
|
||||
]);
|
||||
|
||||
|
|
|
|||
35
src/seps/sep-1933.yaml
Normal file
35
src/seps/sep-1933.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
sep: 1933
|
||||
spec_url: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1933
|
||||
requirements:
|
||||
- check: wif-grant-type
|
||||
text: 'The request includes grant_type: urn:ietf:params:oauth:grant-type:jwt-bearer (RFC 7523 §2.1 — clients MUST use this grant type for JWT-bearer authorization grants)'
|
||||
|
||||
- check: wif-assertion-missing
|
||||
text: 'The request includes assertion: <workload-jwt> (RFC 7523 §2.1 — the assertion parameter is REQUIRED and MUST contain the JWT)'
|
||||
|
||||
- check: wif-assertion-verified
|
||||
text: 'Client successfully authenticates to the MCP authorization server using a JWT-bearer grant with a valid workload identity JWT assertion'
|
||||
|
||||
- check: wif-assertion-expired
|
||||
text: 'The JWT MUST contain an exp claim and the authorization server MUST reject assertions whose exp has passed (RFC 7523 §3 — client MUST surface this error and not silently ignore it)'
|
||||
|
||||
- check: wif-assertion-audience
|
||||
text: 'The JWT MUST contain an aud claim identifying the authorization server (RFC 7523 §3 — client MUST surface invalid audience errors and not silently ignore them)'
|
||||
|
||||
- check: wif-assertion-malformed
|
||||
text: 'The authorization server MUST reject JWTs with invalid signatures or malformed claims (RFC 7523 §3 — client MUST surface verification failures)'
|
||||
|
||||
- check: wif-no-retry
|
||||
text: 'Clients should not retry a JWT-bearer token request after the authorization server has rejected the assertion, including after invalid_scope responses (WARNING until SEP-1933 lands normative spec text; RFC 7523 is silent on client retry behaviour)'
|
||||
|
||||
- check: wif-grant-fallback
|
||||
text: 'When the authorization server returns unauthorized_client for a JWT-bearer token request, the client should not fall back to a different grant type (WARNING until SEP-1933 lands normative spec text; RFC 7523 §2.1 defines the grant but does not forbid grant-type switching after failure)'
|
||||
|
||||
- text: 'The JWT MUST include an iss (issuer) claim identifying the workload identity provider (RFC 7523 §3)'
|
||||
excluded: 'Issuer validation is AS policy; the client presents a pre-signed token and cannot vary the iss value. The scenario does not expose issuer in context because it is not a client-observable parameter — the client has no mechanism to select or alter the iss of an assertion it did not construct.'
|
||||
|
||||
- text: 'The JWT MUST include a sub (subject) claim identifying the workload (RFC 7523 §3)'
|
||||
excluded: 'Subject allowlist enforcement is AS policy; the client has no control over the subject in a pre-signed IdP token. From the client perspective this collapses to a generic invalid_grant response, already covered by existing checks.'
|
||||
|
||||
- text: 'The JWT MUST include a jti (JWT ID) claim to prevent replay (RFC 7523 §3, recommended)'
|
||||
excluded: 'Replay detection is AS policy; the client cannot influence jti uniqueness across requests. No client-observable protocol difference from other invalid_grant responses.'
|
||||
Loading…
Reference in a new issue