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,
|
ClientCredentialsProvider,
|
||||||
PrivateKeyJwtProvider
|
PrivateKeyJwtProvider
|
||||||
} from '@modelcontextprotocol/sdk/client/auth-extensions.js';
|
} 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 { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { ClientConformanceContextSchema } from '../../../src/schemas/context.js';
|
import { ClientConformanceContextSchema } from '../../../src/schemas/context.js';
|
||||||
import { DRAFT_PROTOCOL_VERSION } from '../../../src/types.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);
|
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
|
// 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 express, { type Request, type Response } from 'express';
|
||||||
import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types';
|
import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types';
|
||||||
import { createAuthServer } from './helpers/createAuthServer';
|
import { createAuthServer } from './helpers/createAuthServer';
|
||||||
|
import { JWT_BEARER_GRANT_TYPE } from './helpers/createWorkloadJwt.js';
|
||||||
import { createServer } from './helpers/createServer';
|
import { createServer } from './helpers/createServer';
|
||||||
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
|
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
|
||||||
import { ServerLifecycle } from './helpers/serverLifecycle';
|
import { ServerLifecycle } from './helpers/serverLifecycle';
|
||||||
|
|
@ -88,7 +89,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario {
|
||||||
// Start auth server with JWT bearer grant support only
|
// Start auth server with JWT bearer grant support only
|
||||||
// Token exchange is handled by IdP
|
// Token exchange is handled by IdP
|
||||||
const authApp = createAuthServer(ctx, this.checks, this.authServer.getUrl, {
|
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'],
|
tokenEndpointAuthMethodsSupported: ['client_secret_basic'],
|
||||||
tokenVerifier,
|
tokenVerifier,
|
||||||
onTokenRequest: async ({
|
onTokenRequest: async ({
|
||||||
|
|
@ -99,7 +100,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario {
|
||||||
authorizationHeader
|
authorizationHeader
|
||||||
}) => {
|
}) => {
|
||||||
// Auth server only handles JWT bearer grant (ID-JAG -> access token)
|
// 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`;
|
const mcpResourceUrl = `${this.mcpServer.getUrl()}/mcp`;
|
||||||
return await this.handleJwtBearerGrant(
|
return await this.handleJwtBearerGrant(
|
||||||
body,
|
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 {
|
import {
|
||||||
authScenariosList,
|
authScenariosList,
|
||||||
backcompatScenariosList,
|
backcompatScenariosList,
|
||||||
draftScenariosList
|
draftScenariosList,
|
||||||
|
extensionScenariosList
|
||||||
} from './index';
|
} from './index';
|
||||||
import {
|
import {
|
||||||
runClientAgainstScenario,
|
runClientAgainstScenario,
|
||||||
InlineClientRunner
|
InlineClientRunner
|
||||||
} from './test_helpers/testClient';
|
} from './test_helpers/testClient';
|
||||||
import { runClient as badPrmClient } from '../../../../examples/clients/typescript/auth-test-bad-prm';
|
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 noCimdClient } from '../../../../examples/clients/typescript/auth-test-no-cimd';
|
||||||
import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope';
|
import { runClient as ignoreScopeClient } from '../../../../examples/clients/typescript/auth-test-ignore-scope';
|
||||||
import { runClient as partialScopesClient } from '../../../../examples/clients/typescript/auth-test-partial-scopes';
|
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 { ResourceMismatchScenario } from './resource-mismatch';
|
||||||
import { PreRegistrationScenario } from './pre-registration';
|
import { PreRegistrationScenario } from './pre-registration';
|
||||||
import { EnterpriseManagedAuthorizationScenario } from './enterprise-managed-authorization';
|
import { EnterpriseManagedAuthorizationScenario } from './enterprise-managed-authorization';
|
||||||
|
import { WifJwtBearerScenario } from './wif-jwt-bearer';
|
||||||
import {
|
import {
|
||||||
OfflineAccessScopeScenario,
|
OfflineAccessScopeScenario,
|
||||||
OfflineAccessNotSupportedScenario
|
OfflineAccessNotSupportedScenario
|
||||||
|
|
@ -79,5 +80,6 @@ export const draftScenariosList: Scenario[] = [
|
||||||
new IssParameterWrongIssuerScenario(),
|
new IssParameterWrongIssuerScenario(),
|
||||||
new IssParameterUnexpectedScenario(),
|
new IssParameterUnexpectedScenario(),
|
||||||
new IssParameterNormalizedVariantScenario(),
|
new IssParameterNormalizedVariantScenario(),
|
||||||
new MetadataIssuerMismatchScenario()
|
new MetadataIssuerMismatchScenario(),
|
||||||
|
new WifJwtBearerScenario()
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -109,5 +109,9 @@ export const SpecReferences: { [key: string]: SpecReference } = {
|
||||||
SEP_2207_REFRESH_TOKEN_GUIDANCE: {
|
SEP_2207_REFRESH_TOKEN_GUIDANCE: {
|
||||||
id: 'SEP-2207-Refresh-Token-Guidance',
|
id: 'SEP-2207-Refresh-Token-Guidance',
|
||||||
url: 'https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207'
|
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_id_token: z.string(),
|
||||||
idp_issuer: z.string(),
|
idp_issuer: z.string(),
|
||||||
idp_token_endpoint: 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