@@ -26,7 +26,10 @@ import {
2626 isUnusableSuccessTokenResponse ,
2727 refreshAccessToken ,
2828 shouldRefreshToken ,
29+ shouldSendResourceIndicator ,
30+ type ResourceIndicatorSupport ,
2931} from "./oauth-helpers" ;
32+ import type { OAuthAuthorizationServerMetadata } from "./oauth-discovery" ;
3033import { serveTestHttpApp } from "./testing" ;
3134
3235interface TokenCall {
@@ -1423,6 +1426,233 @@ describe("refreshAccessToken", () => {
14231426 ) ;
14241427} ) ;
14251428
1429+ // ---------------------------------------------------------------------------
1430+ // RFC 8707 resource indicators — the AS capability gate (#1789)
1431+ //
1432+ // THE RULE under test, on every grant that can carry `resource`:
1433+ // no metadata → send (unchanged; MCP's default expectation)
1434+ // metadata + flag true → send
1435+ // metadata + flag absent → omit (RFC 8414 §2: not advertised)
1436+ // metadata + flag false → omit
1437+ //
1438+ // The "metadata + flag absent" row is the bug: Microsoft Entra v2 publishes
1439+ // metadata, does not advertise resource indicators, and rejects `resource`
1440+ // alongside a v2 `scope` with AADSTS9010010.
1441+ // ---------------------------------------------------------------------------
1442+
1443+ const RESOURCE = "https://api.example.com/v1/mcp" ;
1444+ const AS_SUPPORTED : ResourceIndicatorSupport = { resource_indicators_supported : true } ;
1445+ const AS_UNSUPPORTED : ResourceIndicatorSupport = { resource_indicators_supported : false } ;
1446+ /** Metadata that exists but never mentions resource indicators — the Entra
1447+ * shape. Typed as the whole discovered document, not just the one flag, so
1448+ * this fixture stays honest about what a real caller threads through. */
1449+ const AS_SILENT : OAuthAuthorizationServerMetadata = {
1450+ issuer : "https://login.microsoftonline.com/tenant/v2.0" ,
1451+ authorization_endpoint : "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize" ,
1452+ token_endpoint : "https://login.microsoftonline.com/tenant/oauth2/v2.0/token" ,
1453+ } ;
1454+
1455+ describe ( "shouldSendResourceIndicator" , ( ) => {
1456+ it ( "sends when no metadata was discovered" , ( ) => {
1457+ expect ( shouldSendResourceIndicator ( ) ) . toBe ( true ) ;
1458+ expect ( shouldSendResourceIndicator ( undefined ) ) . toBe ( true ) ;
1459+ expect ( shouldSendResourceIndicator ( null ) ) . toBe ( true ) ;
1460+ } ) ;
1461+
1462+ it ( "sends only when the server advertises support" , ( ) => {
1463+ expect ( shouldSendResourceIndicator ( AS_SUPPORTED ) ) . toBe ( true ) ;
1464+ expect ( shouldSendResourceIndicator ( AS_UNSUPPORTED ) ) . toBe ( false ) ;
1465+ expect ( shouldSendResourceIndicator ( AS_SILENT ) ) . toBe ( false ) ;
1466+ expect ( shouldSendResourceIndicator ( { } ) ) . toBe ( false ) ;
1467+ } ) ;
1468+ } ) ;
1469+
1470+ describe ( "buildAuthorizationUrl resource-indicator gating" , ( ) => {
1471+ const baseInput = {
1472+ authorizationUrl : "https://example.com/authorize" ,
1473+ clientId : "client-123" ,
1474+ redirectUrl : "https://app.example.com/callback" ,
1475+ scopes : [ "https://api.fabric.microsoft.com/.default" ] as const ,
1476+ state : "state-abc" ,
1477+ codeChallenge : "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" ,
1478+ resource : RESOURCE ,
1479+ } ;
1480+
1481+ it ( "sends resource when no authorization-server metadata is known" , ( ) => {
1482+ const url = new URL ( buildAuthorizationUrl ( baseInput ) ) ;
1483+ expect ( url . searchParams . get ( "resource" ) ) . toBe ( RESOURCE ) ;
1484+ } ) ;
1485+
1486+ it ( "sends resource when the server advertises resource_indicators_supported" , ( ) => {
1487+ const url = new URL (
1488+ buildAuthorizationUrl ( { ...baseInput , authorizationServerMetadata : AS_SUPPORTED } ) ,
1489+ ) ;
1490+ expect ( url . searchParams . get ( "resource" ) ) . toBe ( RESOURCE ) ;
1491+ } ) ;
1492+
1493+ it ( "omits resource when the server publishes metadata without the capability" , ( ) => {
1494+ for ( const metadata of [ AS_UNSUPPORTED , AS_SILENT ] ) {
1495+ const url = new URL (
1496+ buildAuthorizationUrl ( { ...baseInput , authorizationServerMetadata : metadata } ) ,
1497+ ) ;
1498+ expect ( url . searchParams . has ( "resource" ) ) . toBe ( false ) ;
1499+ // The scope the AS DOES accept must survive the veto untouched.
1500+ expect ( url . searchParams . get ( "scope" ) ) . toBe ( "https://api.fabric.microsoft.com/.default" ) ;
1501+ }
1502+ } ) ;
1503+ } ) ;
1504+
1505+ describe ( "exchangeAuthorizationCode resource-indicator gating" , ( ) => {
1506+ const exchange = (
1507+ tokenUrl : string ,
1508+ authorizationServerMetadata ?: ResourceIndicatorSupport | null ,
1509+ ) =>
1510+ exchangeAuthorizationCode ( {
1511+ tokenUrl,
1512+ clientId : "cid" ,
1513+ redirectUrl : "https://app.example.com/cb" ,
1514+ codeVerifier : "verifier" ,
1515+ code : "abc" ,
1516+ resource : RESOURCE ,
1517+ authorizationServerMetadata,
1518+ } ) ;
1519+
1520+ it . effect ( "sends resource when no authorization-server metadata is known" , ( ) =>
1521+ withTokenEndpoint ( tokenResponse ( validCodeBody ) , ( { tokenUrl, calls } ) =>
1522+ Effect . gen ( function * ( ) {
1523+ yield * exchange ( tokenUrl ) ;
1524+ expect ( ( yield * calls ) [ 0 ] ! . body . get ( "resource" ) ) . toBe ( RESOURCE ) ;
1525+ } ) ,
1526+ ) ,
1527+ ) ;
1528+
1529+ it . effect ( "sends resource when the server advertises support" , ( ) =>
1530+ withTokenEndpoint ( tokenResponse ( validCodeBody ) , ( { tokenUrl, calls } ) =>
1531+ Effect . gen ( function * ( ) {
1532+ yield * exchange ( tokenUrl , AS_SUPPORTED ) ;
1533+ expect ( ( yield * calls ) [ 0 ] ! . body . get ( "resource" ) ) . toBe ( RESOURCE ) ;
1534+ } ) ,
1535+ ) ,
1536+ ) ;
1537+
1538+ it . effect ( "omits resource when the server publishes metadata without the capability" , ( ) =>
1539+ withTokenEndpoint ( tokenResponse ( validCodeBody ) , ( { tokenUrl, calls } ) =>
1540+ Effect . gen ( function * ( ) {
1541+ yield * exchange ( tokenUrl , AS_SILENT ) ;
1542+ yield * exchange ( tokenUrl , AS_UNSUPPORTED ) ;
1543+ const bodies = ( yield * calls ) . map ( ( call ) => call . body ) ;
1544+ expect ( bodies ) . toHaveLength ( 2 ) ;
1545+ for ( const body of bodies ) {
1546+ expect ( body . has ( "resource" ) ) . toBe ( false ) ;
1547+ // The grant itself is untouched — only `resource` is withheld.
1548+ expect ( body . get ( "grant_type" ) ) . toBe ( "authorization_code" ) ;
1549+ expect ( body . get ( "code_verifier" ) ) . toBe ( "verifier" ) ;
1550+ }
1551+ } ) ,
1552+ ) ,
1553+ ) ;
1554+ } ) ;
1555+
1556+ describe ( "exchangeClientCredentials resource-indicator gating" , ( ) => {
1557+ const exchange = (
1558+ tokenUrl : string ,
1559+ authorizationServerMetadata ?: ResourceIndicatorSupport | null ,
1560+ ) =>
1561+ exchangeClientCredentials ( {
1562+ tokenUrl,
1563+ clientId : "cid" ,
1564+ clientSecret : "secret" ,
1565+ scopes : [ "https://api.fabric.microsoft.com/.default" ] ,
1566+ resource : RESOURCE ,
1567+ authorizationServerMetadata,
1568+ } ) ;
1569+
1570+ it . effect ( "sends resource when no authorization-server metadata is known" , ( ) =>
1571+ withTokenEndpoint ( tokenResponse ( validRefreshBody ) , ( { tokenUrl, calls } ) =>
1572+ Effect . gen ( function * ( ) {
1573+ yield * exchange ( tokenUrl ) ;
1574+ expect ( ( yield * calls ) [ 0 ] ! . body . get ( "resource" ) ) . toBe ( RESOURCE ) ;
1575+ } ) ,
1576+ ) ,
1577+ ) ;
1578+
1579+ it . effect ( "sends resource when the server advertises support" , ( ) =>
1580+ withTokenEndpoint ( tokenResponse ( validRefreshBody ) , ( { tokenUrl, calls } ) =>
1581+ Effect . gen ( function * ( ) {
1582+ yield * exchange ( tokenUrl , AS_SUPPORTED ) ;
1583+ expect ( ( yield * calls ) [ 0 ] ! . body . get ( "resource" ) ) . toBe ( RESOURCE ) ;
1584+ } ) ,
1585+ ) ,
1586+ ) ;
1587+
1588+ it . effect ( "omits resource when the server publishes metadata without the capability" , ( ) =>
1589+ withTokenEndpoint ( tokenResponse ( validRefreshBody ) , ( { tokenUrl, calls } ) =>
1590+ Effect . gen ( function * ( ) {
1591+ yield * exchange ( tokenUrl , AS_SILENT ) ;
1592+ yield * exchange ( tokenUrl , AS_UNSUPPORTED ) ;
1593+ const bodies = ( yield * calls ) . map ( ( call ) => call . body ) ;
1594+ expect ( bodies ) . toHaveLength ( 2 ) ;
1595+ for ( const body of bodies ) {
1596+ expect ( body . has ( "resource" ) ) . toBe ( false ) ;
1597+ expect ( body . get ( "grant_type" ) ) . toBe ( "client_credentials" ) ;
1598+ expect ( body . get ( "scope" ) ) . toBe ( "https://api.fabric.microsoft.com/.default" ) ;
1599+ }
1600+ } ) ,
1601+ ) ,
1602+ ) ;
1603+ } ) ;
1604+
1605+ describe ( "refreshAccessToken resource-indicator gating" , ( ) => {
1606+ const refresh = (
1607+ tokenUrl : string ,
1608+ authorizationServerMetadata ?: ResourceIndicatorSupport | null ,
1609+ ) =>
1610+ refreshAccessToken ( {
1611+ tokenUrl,
1612+ clientId : "cid" ,
1613+ refreshToken : "old" ,
1614+ resource : RESOURCE ,
1615+ authorizationServerMetadata,
1616+ } ) ;
1617+
1618+ it . effect ( "sends resource when no authorization-server metadata is known" , ( ) =>
1619+ withTokenEndpoint ( tokenResponse ( validRefreshBody ) , ( { tokenUrl, calls } ) =>
1620+ Effect . gen ( function * ( ) {
1621+ yield * refresh ( tokenUrl ) ;
1622+ expect ( ( yield * calls ) [ 0 ] ! . body . get ( "resource" ) ) . toBe ( RESOURCE ) ;
1623+ } ) ,
1624+ ) ,
1625+ ) ;
1626+
1627+ it . effect ( "sends resource when the server advertises support" , ( ) =>
1628+ withTokenEndpoint ( tokenResponse ( validRefreshBody ) , ( { tokenUrl, calls } ) =>
1629+ Effect . gen ( function * ( ) {
1630+ yield * refresh ( tokenUrl , AS_SUPPORTED ) ;
1631+ expect ( ( yield * calls ) [ 0 ] ! . body . get ( "resource" ) ) . toBe ( RESOURCE ) ;
1632+ } ) ,
1633+ ) ,
1634+ ) ;
1635+
1636+ // Refresh builds its extra params conditionally and passes `undefined` when
1637+ // the set is empty, so the veto must leave a well-formed refresh request
1638+ // rather than an empty `additionalParameters` bag.
1639+ it . effect ( "omits resource when the server publishes metadata without the capability" , ( ) =>
1640+ withTokenEndpoint ( tokenResponse ( validRefreshBody ) , ( { tokenUrl, calls } ) =>
1641+ Effect . gen ( function * ( ) {
1642+ yield * refresh ( tokenUrl , AS_SILENT ) ;
1643+ yield * refresh ( tokenUrl , AS_UNSUPPORTED ) ;
1644+ const bodies = ( yield * calls ) . map ( ( call ) => call . body ) ;
1645+ expect ( bodies ) . toHaveLength ( 2 ) ;
1646+ for ( const body of bodies ) {
1647+ expect ( body . has ( "resource" ) ) . toBe ( false ) ;
1648+ expect ( body . get ( "grant_type" ) ) . toBe ( "refresh_token" ) ;
1649+ expect ( body . get ( "refresh_token" ) ) . toBe ( "old" ) ;
1650+ }
1651+ } ) ,
1652+ ) ,
1653+ ) ;
1654+ } ) ;
1655+
14261656describe ( "shouldRefreshToken" , ( ) => {
14271657 it ( "never refreshes when expiresAt is null" , ( ) => {
14281658 expect ( shouldRefreshToken ( { expiresAt : null } ) ) . toBe ( false ) ;
0 commit comments