Skip to content

feat: add conformance tests for SEP-990#110

Merged
pcarleton merged 7 commits intomodelcontextprotocol:mainfrom
sagar-okta:SEP-990-test
Feb 13, 2026
Merged

feat: add conformance tests for SEP-990#110
pcarleton merged 7 commits intomodelcontextprotocol:mainfrom
sagar-okta:SEP-990-test

Conversation

@sagar-okta
Copy link
Contributor

@sagar-okta sagar-okta commented Jan 21, 2026

Description:

Adds conformance tests for SEP-990 which introduces Enterprise Managed OAuth for machine-to-machine authentication using enterprise identity providers without user interaction.

Summary

  • Server test: Validates complete SEP-990 flow: IDP ID token → ID-JAG → access token
  • Server test: Validates RFC 8693 token exchange at IdP (IDP ID token → ID-JAG)
  • Server test: Validates RFC 7523 JWT bearer grant at Auth Server (ID-JAG → access token)
  • Server test: Validates proper token types (oauth-id-jag+jwt), audience claims, and confidential client authentication

Motivation and Context

SEP-990 introduces Enterprise Managed OAuth, enabling machine-to-machine authentication flows for cross-app access in enterprise environments. This allows applications to authenticate using enterprise identity providers (IdPs) through a two-step OAuth flow:

  1. Token Exchange (RFC 8693): Exchange IDP ID token for ID-bound JSON Assertion Grant (ID-JAG)
  2. JWT Bearer Grant (RFC 7523): Exchange ID-JAG for access token

These conformance tests ensure SDK implementations correctly handle the complete cross-app access flow including proper endpoint separation, token type validation, and audience claim verification.

How Has This Been Tested?

  • All conformance checks pass against TypeScript SDK implementation
  • Tests verify: IdP/Auth Server separation, token exchange flow, jwt-bearer grant flow, ID-JAG format validation, audience claim correctness, confidential client authentication methods
  • Removed redundant partial test scenarios, kept only complete end-to-end flow test

Breaking Changes

None.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

References

@pcarleton
Copy link
Member

🙌 thanks for this! will aim to get you comments by tomorrow, but i'm very excited to have a test for this.

one thing missing from this tool currently is marking tests as optional. Current state is MUST = FAIL, SHOULD = WARNING, and for tiering we want SHOULD to be required (want the best SDK's to do the SHOULD's).

extensions are a new axis, where we want to say "this feature is optional, but within the test, the same MUST/SHOULD logic applies" which will apply here and to client credentials which is currently not differentiated.

I'm just noting the concept we need to add, not in the scope of this PR.

@sagar-okta sagar-okta marked this pull request as ready for review January 23, 2026 05:05
Copy link
Member

@pcarleton pcarleton left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few high level comments:

  • The top of the PR description seems outdated / copy pasted? (the SSE polling bit)
  • It looks like we're mixing AS and IdP endpoints, let's keep those separate w/ separate handlers
  • let's stick to 1 end-to-end test to start w/ many checks. each test that spins up a server is a cost on CI for every SDK, so we want to keep the # low.
  • I stuck with comments on the test since that's the most important part, but the example will also need some changes.
  • if you could include a negative test (i.e. an example client that implements it incorrectly, and so it will fail the test), that'd be great.
  • please add this to the "extensions" list of tests (may need to manage merge conflicts, this jostled a bit for the tiering kickoff)

'urn:ietf:params:oauth:grant-type:token-exchange',
'urn:ietf:params:oauth:grant-type:jwt-bearer'
],
tokenEndpointAuthMethodsSupported: ['none'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i believe this should be client_secret_basic or private_key_jwt since ID-JAG is only supposed to work with confidential clients.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the tokenEndpointAuthMethodsSupported to mentioned parameters. However currently client_secret_basic is considered and private_key_jwt would be checked upon later.

const idpIdToken = await createIdpIdToken(
this.idpPrivateKey!,
this.idpServer.getUrl(),
this.authServer.getUrl()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the IdP ID token should not have the auth server as its audience. it should be the "idp_client_id" which is distinct from the client id used to talk to the authorization server.

it's the id-jag that should have the authServer as its audience.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out, since ID token would generally be provided by user kept this as a bypass value initially but now it is been corrected.

tokenEndpointAuthMethodsSupported: ['none'],
onTokenRequest: async ({ grantType, body, timestamp, authBaseUrl }) => {
// Handle token exchange (IDP ID token -> authorization grant)
if (grantType === 'urn:ietf:params:oauth:grant-type:token-exchange') {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the auth server shouldn't bet getting a token-exchange request, that request should be going to the IdP.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separated the ID-JAG and access token exchange steps.

scopes: [],
additionalFields: {
issued_token_type:
'urn:ietf:params:oauth:token-type:authorization_grant',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the ID-JAG flow, so should be urn:ietf:params:oauth:token-type:id-jag

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

* using RFC 8693 token exchange, and then exchange that grant for an access token
* using RFC 7523 JWT Bearer grant.
*/
export class CrossAppAccessTokenExchangeScenario implements Scenario {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's start with just 1 test for the full flow. each test adds integration cost, and these 2 are redundant with the full e2e test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Removed two separate flow check methods and kept only one full flow step.

// Start auth server with both token exchange and JWT bearer grant support
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
grantTypesSupported: [
'urn:ietf:params:oauth:grant-type:token-exchange',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the auth server doesn't need to support token-exchange, I think you've combined the IdP and AS in this test.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separated the ID-JAG and access token exchange steps.


if (
!subjectToken ||
subjectTokenType !== 'urn:ietf:params:oauth:token-type:id_token'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the id_token should never be hitting the AS url, this function is called in the AS.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separated the ID-JAG and access token exchange steps.

sub: userId,
grant_type: 'authorization_grant'
})
.setProtectedHeader({ alg: 'ES256' })
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.setProtectedHeader({ alg: 'ES256', typ: 'oauth-id-jag+jwt' })

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return {
token: authorizationGrant,
scopes: [],
additionalFields: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't think this field is respected. since this handler needs to be on the IdP anyway, it's probably better to implement a handler on the fake IdP directly rather than try to shoehorn into the createAuthServer interfaces

@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 27, 2026

Open in StackBlitz

npx https://pkg.pr.new/modelcontextprotocol/conformance/@modelcontextprotocol/conformance@110

commit: 406ee27

@sagar-okta
Copy link
Contributor Author

Hi @pcarleton , Thanks for the review. I've made the changes as required and rebased with latest main, please have a look at the new changes once you are available.


// Step 1: Token Exchange (IDP ID token -> ID-JAG)
logger.debug('Step 1: Exchanging IDP ID token for ID-JAG at IdP...');
const tokenExchangeParams = new URLSearchParams({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx

Parameter Required/Optional Description Example/Allowed Values
requested_token_type REQUIRED Indicates that an ID Assertion JWT is being requested. urn:ietf:params:oauth:token-type:id-jag
audience REQUIRED The Issuer URL of the MCP server's authorization server. https://auth.chat.example/
resource REQUIRED The RFC9728 Resource Identifier of the MCP server. https://mcp.chat.example/
scope OPTIONAL The space-separated list of scopes at the MCP Server that are being requested. scope1 scope2
subject_token REQUIRED The identity assertion (e.g. the OpenID Connect ID Token or SAML assertion) for the target end-user. (JWT or SAML assertion string)
subject_token_type REQUIRED Indicates the type of the security token in the subject_token parameter, as specified in RFC8693 Section 3. urn:ietf:params:oauth:token-type:id_token (OIDC)urn:ietf:params:oauth:token-type:saml2 (SAML)

we're missing several required parameters here.

const jwtBearerParams = new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: idJag,
client_id: ctx.client_id
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this needs to be a distinct client id from the one used for the IdP

grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: ctx.idp_id_token,
subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
client_id: ctx.client_id
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
client_id: ctx.client_id
client_id: ctx.idp_client_id

idp_id_token: idpIdToken,
idp_issuer: this.idpServer.getUrl(),
idp_token_endpoint: `${this.idpServer.getUrl()}/token`,
auth_server_url: this.authServer.getUrl()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because we need resource as well, I think it's better to get this via discovery (i.e. not provide via context)

}
);

this.checks.push({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should verify the full set of required params for the token exchange

* Cross-app access: Token Exchange (RFC 8693)
* Tests the first step of SEP-990 where IDP ID token is exchanged for authorization grant.
*/
export async function runCrossAppAccessTokenExchange(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these other scenarios are unused, please delete

- Delete unused separate token-exchange and jwt-bearer scenarios,
  keeping only the complete e2e flow (review comment)
- Add missing required token exchange params per SEP-990 spec:
  requested_token_type, audience, resource (review comment)
- Use ctx.idp_client_id for token exchange client_id instead of
  AS client_id (review comment)
- Client discovers resource and auth server via PRM metadata
  instead of receiving auth_server_url via context (review comment)
- Server IdP handler verifies all required token exchange params
  with detailed error messages (review comment)
- Add resource, client_id, jti claims to ID-JAG per SEP-990 spec
- Verify ID-JAG typ header (oauth-id-jag+jwt) in JWT bearer handler
- Remove auth_server_url from context schema
Server-side (AS) now verifies:
- client_secret_basic authentication on JWT bearer grant
- ID-JAG typ header is oauth-id-jag+jwt
- ID-JAG client_id claim matches the authenticating client (Section 5.1)
- ID-JAG resource claim matches the MCP server resource identifier
- Client credentials provided via context (client_secret)

Server-side (IdP) now:
- Sets ID-JAG client_id to the MCP Client's AS client_id (not the
  IdP client_id), per Section 6.1

Example client now:
- Authenticates to AS via client_secret_basic (Authorization: Basic)
  instead of sending client_id in body
- Checks AS metadata grant_types_supported includes jwt-bearer
  before attempting the flow
- Add shared MockTokenVerifier between AS and MCP server so the MCP
  server only accepts tokens actually issued by the auth server,
  matching the pattern used by all other auth scenarios
- Remove private_key_jwt from tokenEndpointAuthMethodsSupported since
  the handler only implements client_secret_basic
@pcarleton pcarleton dismissed their stale review February 13, 2026 17:46

(addressed)

@pcarleton pcarleton merged commit 83c446d into modelcontextprotocol:main Feb 13, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants