Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ When the flow breaks, work the failure top-down: discovery -> agent-connect -> a

- The grant doesn't cover this stream/field/filter combination. Don't fall back to a broader grant in the background. Request an upgrade and explain to the user why.

**Symptom:** `403 grant_revoked`.
**Symptom:** `401 invalid_token` with `grant_revoked` in the error body.

- The owner revoked. Stop. Don't request a replacement automatically; ask the user.

Expand Down
19 changes: 11 additions & 8 deletions reference-implementation/server/auth-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,15 @@ function httpQuotedString(value: unknown): string {
return String(value).replace(/["\\]/g, "\\$&");
}

function setProtectedResourceMetadataChallenge(res: ResponseLike): void {
function setProtectedResourceMetadataChallenge(res: ResponseLike, invalidToken = false): void {
const metadataUrl = getProtectedResourceMetadataUrl(res);
if (!metadataUrl) {
return;
}
res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${httpQuotedString(metadataUrl)}"`);
res.setHeader(
"WWW-Authenticate",
`Bearer ${invalidToken ? 'error="invalid_token", ' : ""}resource_metadata="${httpQuotedString(metadataUrl)}"`
);
}

// ─── Auth-gate query context ─────────────────────────────────────────────────
Expand Down Expand Up @@ -187,29 +190,29 @@ async function emitInactiveTokenQuery(req: RequestLike, res: ResponseLike, info:
Object.assign(new Error("Token rejected at auth gate"), {
code: info.inactive_reason || "authentication_error",
message: inactiveTokenMessage(info.inactive_reason),
})
}),
401
);
}

async function respondToInactiveToken(req: RequestLike, res: ResponseLike, info: InactiveTokenInfo): Promise<void> {
if (info.trace_id) {
setReferenceTraceId(res, info.trace_id);
}
setProtectedResourceMetadataChallenge(res, true);
await emitInactiveTokenQuery(req, res, info);
if (info.inactive_reason === "grant_revoked") {
return pdppError(res, 403, "grant_revoked", "Grant has been revoked");
return pdppError(res, 401, "grant_revoked", "Grant has been revoked");
}
if (info.inactive_reason === "grant_expired") {
return pdppError(res, 403, "grant_expired", "Grant has expired");
return pdppError(res, 401, "grant_expired", "Grant has expired");
}
if (info.inactive_reason === "grant_invalid") {
return pdppError(res, 403, "grant_invalid", "Grant is malformed or no longer valid");
return pdppError(res, 401, "authentication_error", "Invalid or expired token");
}
if (info.inactive_reason === "authorization_state.unsupported_legacy_shape") {
setProtectedResourceMetadataChallenge(res);
return pdppError(res, 401, info.inactive_reason, "Fresh consent is required");
}
setProtectedResourceMetadataChallenge(res);
return pdppError(res, 401, "authentication_error", "Invalid or expired token");
}

Expand Down
34 changes: 17 additions & 17 deletions reference-implementation/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ import {
selfCallUrlHostForBindHost,
validateReachabilityContract,
} from "./reachability-contract.ts";
import { createPackageRsClient, createRsClient } from "./package-rs-client.ts";
import { createPackageProfile, createRsClient } from "./package-rs-client.ts";
import { reconcilePolyfillManifests } from "./polyfill-manifest-reconcile.ts";
import { postgresPersistContentAddressedBlob } from "./postgres-records.ts";
import {
Expand Down Expand Up @@ -1489,12 +1489,15 @@ function getProtectedResourceMetadataUrl(res: ResLike) {
return typeof metadataUrl === "string" && metadataUrl ? metadataUrl : null;
}

function setProtectedResourceMetadataChallenge(res: ResLike) {
function setProtectedResourceMetadataChallenge(res: ResLike, invalidToken = false) {
const metadataUrl = getProtectedResourceMetadataUrl(res);
if (!metadataUrl) {
return;
}
res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${httpQuotedString(metadataUrl)}"`);
res.setHeader(
"WWW-Authenticate",
`Bearer ${invalidToken ? 'error="invalid_token", ' : ""}resource_metadata="${httpQuotedString(metadataUrl)}"`
);
}

function sha256Hex(value: string) {
Expand Down Expand Up @@ -1780,13 +1783,13 @@ function inferAuthGateQueryContext(req: ReqLike, _tokenInfo: TokenInfo = {}) {
return null;
}

async function emitQueryRejected(context: QueryContext, req: ReqLike, err: ApiError) {
async function emitQueryRejected(context: QueryContext, req: ReqLike, err: ApiError, statusOverride?: number) {
// biome-ignore lint/suspicious/noUnnecessaryConditions: TypeScript boundary permits nullish input; this guard preserves runtime behavior.
if (!context?.queryId) {
return;
}
const code = err.code || "api_error";
const status = codeToStatus[code] || 500;
const status = statusOverride ?? codeToStatus[code] ?? 500;
const data: Record<string, unknown> = {
...(context.queryData || {}),
error: {
Expand Down Expand Up @@ -2093,6 +2096,7 @@ async function requireTokenWithIntrospection(
}
}
if (!info.active) {
setProtectedResourceMetadataChallenge(res, true);
if (info.trace_id) {
setReferenceTraceId(res, info.trace_id);
}
Expand Down Expand Up @@ -2130,29 +2134,25 @@ async function requireTokenWithIntrospection(
info.inactive_reason === "grant_invalid"
? "Grant is malformed or no longer valid"
: "Invalid or expired token";
const gateErr = Object.assign(new Error(gateErrMsg), {
code: info.inactive_reason || "authentication_error",
}) as ApiError;
await emitQueryRejected(authGateContext, req, gateErr);
const inactiveReason = info.inactive_reason || "authentication_error";
const gateErr = Object.assign(new Error(gateErrMsg), { code: inactiveReason }) as ApiError;
await emitQueryRejected(authGateContext, req, gateErr, 401);
}
if (info.inactive_reason === "grant_revoked") {
return pdppError(res, 403, "grant_revoked", "Grant has been revoked");
return pdppError(res, 401, "grant_revoked", "Grant has been revoked");
}
if (info.inactive_reason === "grant_expired") {
return pdppError(res, 403, "grant_expired", "Grant has expired");
return pdppError(res, 401, "grant_expired", "Grant has expired");
}
if (info.inactive_reason === "grant_invalid") {
return pdppError(res, 403, "grant_invalid", "Grant is malformed or no longer valid");
return pdppError(res, 401, "authentication_error", "Invalid or expired token");
}
if (info.inactive_reason?.startsWith("context.")) {
setProtectedResourceMetadataChallenge(res);
return pdppError(res, 401, info.inactive_reason, "Token introspection failed closed");
}
if (info.inactive_reason === "authorization_state.unsupported_legacy_shape") {
setProtectedResourceMetadataChallenge(res);
return pdppError(res, 401, info.inactive_reason, "Fresh consent is required");
}
setProtectedResourceMetadataChallenge(res);
return pdppError(res, 401, "authentication_error", "Invalid or expired token");
}
req.tokenInfo = info;
Expand Down Expand Up @@ -7469,10 +7469,10 @@ function buildRsApp(opts: ServerOpts = {}) {
// extraction: same `requireTrustedHostedMcpResource` host guard, same
// `setHostedMcpProtectedResourceMetadata` middleware, same
// `requireToken` + `requireClientOrMcpPackage` auth posture, same
// package-token → PackageRsClient fan-out, same single-bearer path,
// package-token → grant_id-selected child, same single-bearer client path,
// same response envelope and headers.
mountRsHostedMcp(app, {
createPackageRsClient,
createPackageProfile,
createRsClient,
explicitResource,
getGrantPackageAccess,
Expand Down
Loading