Auth hardening CI gaps + SDK consolidation: EMA leg 2 / scopes (#1527) (#1725)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Close #1527 acceptance gaps with automated tests (SEP-2468 iss, SEP-2207 offline_access, SEP-2351 discovery shapes, SEP-2352 AS-migration, EraNegotiationFailed→401 unwrap); lean EMA leg 2 and scope helpers on the v2 SDK; drop deprecated TS baseUrl. Reviewed LGTM.
This commit is contained in:
parent
ec185f1053
commit
a71e6d2012
4 changed files with 1513 additions and 852 deletions
|
|
@ -67,11 +67,13 @@ export async function mintEmaResourceTokens(
|
|||
}
|
||||
|
||||
const audience = ctx.resourceAsUrl.href.replace(/\/$/, "");
|
||||
// SDK leg-2 helper requires `resource` on the wire; fall back to PRM `resource`.
|
||||
const resource = ctx.resourceUrl?.href ?? ctx.resourceMetadata.resource;
|
||||
const idJag = await exchangeIdJag({
|
||||
idp: config.idp,
|
||||
idToken,
|
||||
audience,
|
||||
resource: ctx.resourceUrl?.href,
|
||||
resource,
|
||||
scope: ctx.scope,
|
||||
fetchFn: config.fetchFn,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
import { discoverAuthorizationServerMetadata } from "@modelcontextprotocol/client";
|
||||
import { discoverAndRequestJwtAuthGrant } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
OAuthClientInformation,
|
||||
OAuthTokens,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { OAuthTokensSchema } from "@modelcontextprotocol/core";
|
||||
import type { EnterpriseManagedAuthIdpConfig } from "../../client/types.js";
|
||||
import {
|
||||
GRANT_TYPE_JWT_BEARER,
|
||||
GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
TOKEN_TYPE_ID_JAG,
|
||||
TOKEN_TYPE_ID_TOKEN,
|
||||
} from "./constants.js";
|
||||
import { GRANT_TYPE_JWT_BEARER } from "./constants.js";
|
||||
import { parseHttpUrl } from "../utils.js";
|
||||
import { discoverResourceAsMetadata } from "./resourceContext.js";
|
||||
import { normalizeIdpIssuer } from "./storage.js";
|
||||
|
|
@ -19,70 +14,64 @@ import {
|
|||
postOAuthTokenRequest,
|
||||
} from "./tokenEndpoint.js";
|
||||
|
||||
/** Leg 2 — exchange ID Token for ID-JAG at the enterprise IdP (RFC 8693). */
|
||||
/**
|
||||
* Leg 2 — exchange ID Token for ID-JAG at the enterprise IdP (RFC 8693).
|
||||
*
|
||||
* Delegates to SDK `discoverAndRequestJwtAuthGrant`. `resource` is required
|
||||
* (the SDK always sends it on the wire). Callers should pass
|
||||
* `resourceUrl ?? resourceMetadata.resource` from EMA resource context discovery.
|
||||
*/
|
||||
export async function exchangeIdJag(params: {
|
||||
idp: EnterpriseManagedAuthIdpConfig;
|
||||
idToken: string;
|
||||
audience: string;
|
||||
resource?: string;
|
||||
/** RFC 8707 resource indicator — required by the SDK Layer-2 helper. */
|
||||
resource: string;
|
||||
scope?: string;
|
||||
fetchFn?: typeof fetch;
|
||||
}): Promise<string> {
|
||||
const issuer = normalizeIdpIssuer(params.idp.issuer);
|
||||
const issuerUrl = parseHttpUrl(issuer, "EMA IdP issuer (Client Settings)");
|
||||
const idpMetadata = await discoverAuthorizationServerMetadata(issuerUrl, {
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
if (!idpMetadata?.token_endpoint) {
|
||||
throw new Error("IdP metadata missing token_endpoint");
|
||||
const resource = params.resource.trim();
|
||||
if (!resource) {
|
||||
throw new Error("EMA leg 2 requires a resource identifier");
|
||||
}
|
||||
|
||||
const clientInformation = {
|
||||
client_id: params.idp.clientId,
|
||||
client_secret: params.idp.clientSecret,
|
||||
token_endpoint_auth_method: "client_secret_post",
|
||||
} as OAuthClientInformation;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: GRANT_TYPE_TOKEN_EXCHANGE,
|
||||
requested_token_type: TOKEN_TYPE_ID_JAG,
|
||||
subject_token: params.idToken,
|
||||
subject_token_type: TOKEN_TYPE_ID_TOKEN,
|
||||
audience: params.audience,
|
||||
});
|
||||
if (params.resource) {
|
||||
body.set("resource", params.resource);
|
||||
}
|
||||
if (params.scope) {
|
||||
body.set("scope", params.scope);
|
||||
let result: Awaited<ReturnType<typeof discoverAndRequestJwtAuthGrant>>;
|
||||
try {
|
||||
result = await discoverAndRequestJwtAuthGrant({
|
||||
idpUrl: issuer,
|
||||
audience: params.audience,
|
||||
resource,
|
||||
idToken: params.idToken,
|
||||
clientId: params.idp.clientId,
|
||||
clientSecret: params.idp.clientSecret,
|
||||
scope: params.scope,
|
||||
fetchFn: params.fetchFn,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// Drift-sensitive: `@modelcontextprotocol/client` currently throws plain
|
||||
// `Error`s (not typed SdkError codes) for these failure modes. Remap to
|
||||
// stable Inspector UX strings by matching SDK wording. Prefer typed errors
|
||||
// when the SDK grows them. Canaries in `wire.test.ts` call the real helper
|
||||
// so a wording change fails CI instead of silently falling through.
|
||||
if (/Failed to discover token endpoint/i.test(message)) {
|
||||
throw new Error("IdP metadata missing token_endpoint", { cause: err });
|
||||
}
|
||||
if (/Invalid token exchange response/i.test(message)) {
|
||||
throw new Error("IdP token exchange did not return an ID-JAG", {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
throw new Error(`EMA leg 2 (IdP token exchange for ID-JAG): ${message}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await postOAuthTokenRequest(
|
||||
parseHttpUrl(
|
||||
idpMetadata.token_endpoint,
|
||||
"IdP token_endpoint (from OIDC discovery)",
|
||||
),
|
||||
body,
|
||||
idpMetadata,
|
||||
clientInformation,
|
||||
params.fetchFn,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseOAuthTokenErrorResponse(
|
||||
response,
|
||||
"EMA leg 2 (IdP token exchange for ID-JAG)",
|
||||
);
|
||||
}
|
||||
|
||||
const json = (await response.json()) as {
|
||||
access_token?: string;
|
||||
issued_token_type?: string;
|
||||
};
|
||||
const idJag = json.access_token;
|
||||
if (!idJag) {
|
||||
if (!result.jwtAuthGrant) {
|
||||
throw new Error("IdP token exchange did not return an ID-JAG");
|
||||
}
|
||||
return idJag;
|
||||
return result.jwtAuthGrant;
|
||||
}
|
||||
|
||||
/** Leg 3 — redeem ID-JAG for MCP resource access token (RFC 7523). */
|
||||
|
|
|
|||
|
|
@ -1,46 +1,22 @@
|
|||
/**
|
||||
* SEP-2350 scope helpers aligned with v2 `@modelcontextprotocol/client` exports.
|
||||
* On SDK v2 upgrade, delete this module and import `computeScopeUnion` /
|
||||
* `isStrictScopeSuperset` from the client package instead.
|
||||
* SEP-2350 scope helpers. Union / strict-superset algorithms come from
|
||||
* `@modelcontextprotocol/client`; Inspector-only persistence helpers stay local.
|
||||
*/
|
||||
|
||||
import {
|
||||
parseScopeString,
|
||||
unionAuthorizationScopes,
|
||||
} from "./challenge.js";
|
||||
computeScopeUnion as sdkComputeScopeUnion,
|
||||
isStrictScopeSuperset as sdkIsStrictScopeSuperset,
|
||||
} from "@modelcontextprotocol/client";
|
||||
|
||||
/** Union space-delimited scope strings (order-preserving, deduped). */
|
||||
export function computeScopeUnion(
|
||||
...scopes: ReadonlyArray<string | undefined>
|
||||
): string | undefined {
|
||||
let merged: string | undefined;
|
||||
for (const scope of scopes) {
|
||||
merged = unionAuthorizationScopes(merged, parseScopeString(scope)).join(
|
||||
" ",
|
||||
);
|
||||
if (!merged) {
|
||||
merged = undefined;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
export const computeScopeUnion = sdkComputeScopeUnion;
|
||||
|
||||
/**
|
||||
* Whether `union` contains a scope token not present in `current`.
|
||||
* When the AS omits `scope` on the token response, `current` is empty and any
|
||||
* non-empty union is a strict superset — step-up must re-authorize, not refresh.
|
||||
*/
|
||||
export function isStrictScopeSuperset(
|
||||
union: string | undefined,
|
||||
current: string | undefined,
|
||||
): boolean {
|
||||
if (!union) return false;
|
||||
const currentSet = new Set((current ?? "").split(/\s+/).filter(Boolean));
|
||||
for (const token of union.split(/\s+/)) {
|
||||
if (token && !currentSet.has(token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
export const isStrictScopeSuperset = sdkIsStrictScopeSuperset;
|
||||
|
||||
/**
|
||||
* Scope to persist after a successful token grant (RFC 6749 §5.1).
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { UnauthorizedError } from "@modelcontextprotocol/client";
|
||||
import type { CallbackParams } from "./types.js";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
|
|
@ -172,13 +173,50 @@ export const generateOAuthErrorDescription = (
|
|||
* transport preserves the status on the error object; as a fallback, match
|
||||
* transport wording `"failed …(401)"` so unrelated `(401)` in messages does
|
||||
* not trigger OAuth.
|
||||
*
|
||||
* Under `protocolEra: auto|modern`, a negotiation-probe 401 can surface as
|
||||
* `SdkError(EraNegotiationFailed)` with the real {@link UnauthorizedError} at
|
||||
* `error.data.cause` (and sometimes native `error.cause`). Walk that chain so
|
||||
* OAuth recovery still starts.
|
||||
*/
|
||||
export function isUnauthorizedError(err: unknown): boolean {
|
||||
if (typeof err === "object" && err !== null) {
|
||||
const status = (err as { status?: number; code?: number }).status;
|
||||
const code = (err as { code?: number }).code;
|
||||
if (status === 401 || code === 401) return true;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return /\bfailed\b[^\n]*\(401\)/i.test(message);
|
||||
return isUnauthorizedErrorDeep(err, new Set());
|
||||
}
|
||||
|
||||
function isUnauthorizedErrorDeep(err: unknown, seen: Set<unknown>): boolean {
|
||||
if (err == null) return false;
|
||||
if (typeof err !== "object") {
|
||||
return /\bfailed\b[^\n]*\(401\)/i.test(String(err));
|
||||
}
|
||||
if (seen.has(err)) return false;
|
||||
seen.add(err);
|
||||
|
||||
if (UnauthorizedError.isInstance(err)) return true;
|
||||
|
||||
const status = (err as { status?: number }).status;
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (status === 401 || code === 401) return true;
|
||||
|
||||
if (err instanceof Error && /\bfailed\b[^\n]*\(401\)/i.test(err.message)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
"cause" in err &&
|
||||
isUnauthorizedErrorDeep((err as { cause: unknown }).cause, seen)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const data = (err as { data?: unknown }).data;
|
||||
if (
|
||||
data !== null &&
|
||||
typeof data === "object" &&
|
||||
"cause" in data &&
|
||||
isUnauthorizedErrorDeep((data as { cause: unknown }).cause, seen)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue