Skip to content
Merged
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
10 changes: 10 additions & 0 deletions enterprise/authentication/authentication.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,16 @@ struct Authentication::Impl {
resolved.claims_parameter_supported =
document.value().supports_claims_parameter();

const auto *advertised{
document.value().data().try_at("claims_supported")};
if (advertised != nullptr && advertised->is_array()) {
for (const auto &claim : advertised->as_array()) {
if (claim.is_string()) {
resolved.claims_supported.push_back(claim.to_string());
}
}
}

cached.source = server;
cached.resolved = std::move(resolved);
}
Expand Down
2 changes: 2 additions & 0 deletions enterprise/e2e/auth-mcp-path/hurl/mcp-prm.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ document_schema: header "Link" regex "</registry([^>]+)>"
{
"resource": "{{base}}/registry/self/v1/mcp",
"authorization_servers": [ "https://accounts.example.com" ],
"scopes_supported": [ "mcp:invoke", "mcp:read" ],
"bearer_methods_supported": [ "header" ]
}

Expand Down Expand Up @@ -97,6 +98,7 @@ slash_body: body
{
"resource": "{{base}}/registry/self/v1/mcp",
"authorization_servers": [ "https://accounts.example.com" ],
"scopes_supported": [ "mcp:invoke", "mcp:read" ],
"bearer_methods_supported": [ "header" ]
}

Expand Down
8 changes: 7 additions & 1 deletion enterprise/e2e/auth-mcp-path/one.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@
"audience": "https://registry:8000/registry/self/v1/mcp",
"algorithms": [
"RS256"
]
],
"claims": {
"scope": [
"mcp:invoke",
"mcp:read"
]
}
}
],
"contents": {
Expand Down
6 changes: 3 additions & 3 deletions enterprise/e2e/auth/hurl/mcp-resources.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ jsonpath "$.result.resources[27].uri" == "{{base}}/self/v1/schemas/mcp/prm/respo
jsonpath "$.result.resources[27].name" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.result.resources[27].description" == "The OAuth 2.0 Protected Resource Metadata describing where a token for this instance's Model Context Protocol endpoint is obtained, as defined by RFC 9728"
jsonpath "$.result.resources[27].mimeType" == "application/schema+json"
jsonpath "$.result.resources[27].size" == 1508
jsonpath "$.result.resources[27].size" == 2176
jsonpath "$.result.resources[27].annotations.priority" == 0
jsonpath "$.result.resources[28].uri" == "{{base}}/self/v1/schemas/mcp/request"
jsonpath "$.result.resources[28].name" == "Sourcemeta One MCP Request"
Expand Down Expand Up @@ -642,7 +642,7 @@ jsonpath "$.result.resources[30].uri" == "{{base}}/self/v1/schemas/mcp/prm/respo
jsonpath "$.result.resources[30].name" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.result.resources[30].description" == "The OAuth 2.0 Protected Resource Metadata describing where a token for this instance's Model Context Protocol endpoint is obtained, as defined by RFC 9728"
jsonpath "$.result.resources[30].mimeType" == "application/schema+json"
jsonpath "$.result.resources[30].size" == 1508
jsonpath "$.result.resources[30].size" == 2176
jsonpath "$.result.resources[30].annotations.priority" == 0
jsonpath "$.result.resources[31].uri" == "{{base}}/self/v1/schemas/mcp/request"
jsonpath "$.result.resources[31].name" == "Sourcemeta One MCP Request"
Expand Down Expand Up @@ -1073,7 +1073,7 @@ jsonpath "$.result.resources[28].uri" == "{{base}}/self/v1/schemas/mcp/prm/respo
jsonpath "$.result.resources[28].name" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.result.resources[28].description" == "The OAuth 2.0 Protected Resource Metadata describing where a token for this instance's Model Context Protocol endpoint is obtained, as defined by RFC 9728"
jsonpath "$.result.resources[28].mimeType" == "application/schema+json"
jsonpath "$.result.resources[28].size" == 1508
jsonpath "$.result.resources[28].size" == 2176
jsonpath "$.result.resources[28].annotations.priority" == 0
jsonpath "$.result.resources[29].uri" == "{{base}}/self/v1/schemas/mcp/request"
jsonpath "$.result.resources[29].name" == "Sourcemeta One MCP Request"
Expand Down
4 changes: 2 additions & 2 deletions enterprise/e2e/html/hurl/mcp-2025-11-25-gzip.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -710,7 +710,7 @@ jsonpath "$.result.resources[1].uri" == "{{base}}/self/v1/schemas/mcp/prm/respon
jsonpath "$.result.resources[1].name" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.result.resources[1].description" == "The OAuth 2.0 Protected Resource Metadata describing where a token for this instance's Model Context Protocol endpoint is obtained, as defined by RFC 9728"
jsonpath "$.result.resources[1].mimeType" == "application/schema+json"
jsonpath "$.result.resources[1].size" == 1508
jsonpath "$.result.resources[1].size" == 2176
jsonpath "$.result.resources[1].annotations.priority" == 0
jsonpath "$.result.resources[2].uri" == "{{base}}/self/v1/schemas/mcp/request"
jsonpath "$.result.resources[2].name" == "Sourcemeta One MCP Request"
Expand Down Expand Up @@ -987,7 +987,7 @@ jsonpath "$.result.resources[1].uri" == "{{base}}/self/v1/schemas/mcp/prm/respon
jsonpath "$.result.resources[1].name" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.result.resources[1].description" == "The OAuth 2.0 Protected Resource Metadata describing where a token for this instance's Model Context Protocol endpoint is obtained, as defined by RFC 9728"
jsonpath "$.result.resources[1].mimeType" == "application/schema+json"
jsonpath "$.result.resources[1].size" == 1508
jsonpath "$.result.resources[1].size" == 2176
jsonpath "$.result.resources[1].annotations.priority" == 0
jsonpath "$.result.resources[2].uri" == "{{base}}/self/v1/schemas/mcp/request"
jsonpath "$.result.resources[2].name" == "Sourcemeta One MCP Request"
Expand Down
2 changes: 1 addition & 1 deletion enterprise/e2e/html/hurl/mcp-2025-11-25-resources.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,7 @@ jsonpath "$.result.resources[1].uri" == "{{base}}/self/v1/schemas/mcp/prm/respon
jsonpath "$.result.resources[1].name" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.result.resources[1].description" == "The OAuth 2.0 Protected Resource Metadata describing where a token for this instance's Model Context Protocol endpoint is obtained, as defined by RFC 9728"
jsonpath "$.result.resources[1].mimeType" == "application/schema+json"
jsonpath "$.result.resources[1].size" == 1508
jsonpath "$.result.resources[1].size" == 2176
jsonpath "$.result.resources[1].annotations.priority" == 0
jsonpath "$.result.resources[2].uri" == "{{base}}/self/v1/schemas/mcp/request"
jsonpath "$.result.resources[2].name" == "Sourcemeta One MCP Request"
Expand Down
2 changes: 1 addition & 1 deletion enterprise/e2e/path/hurl/mcp-2025-03-26.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ jsonpath "$.result.resources[32].uri" == "{{base}}/v1/catalog/self/v1/schemas/mc
jsonpath "$.result.resources[32].name" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.result.resources[32].description" == "The OAuth 2.0 Protected Resource Metadata describing where a token for this instance's Model Context Protocol endpoint is obtained, as defined by RFC 9728"
jsonpath "$.result.resources[32].mimeType" == "application/schema+json"
jsonpath "$.result.resources[32].size" == 1519
jsonpath "$.result.resources[32].size" == 2187
jsonpath "$.result.resources[32].annotations.priority" == 0
jsonpath "$.result.resources[33].uri" == "{{base}}/v1/catalog/self/v1/schemas/mcp/request"
jsonpath "$.result.resources[33].name" == "Sourcemeta One MCP Request"
Expand Down
2 changes: 1 addition & 1 deletion enterprise/e2e/path/hurl/mcp-2025-06-18.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ jsonpath "$.result.resources[32].uri" == "{{base}}/v1/catalog/self/v1/schemas/mc
jsonpath "$.result.resources[32].name" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.result.resources[32].description" == "The OAuth 2.0 Protected Resource Metadata describing where a token for this instance's Model Context Protocol endpoint is obtained, as defined by RFC 9728"
jsonpath "$.result.resources[32].mimeType" == "application/schema+json"
jsonpath "$.result.resources[32].size" == 1519
jsonpath "$.result.resources[32].size" == 2187
jsonpath "$.result.resources[32].annotations.priority" == 0
jsonpath "$.result.resources[33].uri" == "{{base}}/v1/catalog/self/v1/schemas/mcp/request"
jsonpath "$.result.resources[33].name" == "Sourcemeta One MCP Request"
Expand Down
4 changes: 2 additions & 2 deletions enterprise/e2e/path/hurl/mcp-2025-11-25-gzip.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@ jsonpath "$.result.resources[32].uri" == "{{base}}/v1/catalog/self/v1/schemas/mc
jsonpath "$.result.resources[32].name" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.result.resources[32].description" == "The OAuth 2.0 Protected Resource Metadata describing where a token for this instance's Model Context Protocol endpoint is obtained, as defined by RFC 9728"
jsonpath "$.result.resources[32].mimeType" == "application/schema+json"
jsonpath "$.result.resources[32].size" == 1519
jsonpath "$.result.resources[32].size" == 2187
jsonpath "$.result.resources[32].annotations.priority" == 0
jsonpath "$.result.resources[33].uri" == "{{base}}/v1/catalog/self/v1/schemas/mcp/request"
jsonpath "$.result.resources[33].name" == "Sourcemeta One MCP Request"
Expand Down Expand Up @@ -1229,7 +1229,7 @@ jsonpath "$.result.resources[32].uri" == "{{base}}/v1/catalog/self/v1/schemas/mc
jsonpath "$.result.resources[32].name" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.result.resources[32].description" == "The OAuth 2.0 Protected Resource Metadata describing where a token for this instance's Model Context Protocol endpoint is obtained, as defined by RFC 9728"
jsonpath "$.result.resources[32].mimeType" == "application/schema+json"
jsonpath "$.result.resources[32].size" == 1519
jsonpath "$.result.resources[32].size" == 2187
jsonpath "$.result.resources[32].annotations.priority" == 0
jsonpath "$.result.resources[33].uri" == "{{base}}/v1/catalog/self/v1/schemas/mcp/request"
jsonpath "$.result.resources[33].name" == "Sourcemeta One MCP Request"
Expand Down
2 changes: 1 addition & 1 deletion enterprise/e2e/path/hurl/mcp-2025-11-25-resources.all.hurl
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ jsonpath "$.result.resources[32].uri" == "{{base}}/v1/catalog/self/v1/schemas/mc
jsonpath "$.result.resources[32].name" == "Sourcemeta One MCP Protected Resource Metadata Response"
jsonpath "$.result.resources[32].description" == "The OAuth 2.0 Protected Resource Metadata describing where a token for this instance's Model Context Protocol endpoint is obtained, as defined by RFC 9728"
jsonpath "$.result.resources[32].mimeType" == "application/schema+json"
jsonpath "$.result.resources[32].size" == 1519
jsonpath "$.result.resources[32].size" == 2187
jsonpath "$.result.resources[32].annotations.priority" == 0
jsonpath "$.result.resources[33].uri" == "{{base}}/v1/catalog/self/v1/schemas/mcp/request"
jsonpath "$.result.resources[33].name" == "Sourcemeta One MCP Request"
Expand Down
45 changes: 45 additions & 0 deletions enterprise/index/enterprise_index.cc
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,41 @@ auto generate_mcp_tools(const sourcemeta::core::URITemplateRouterView &router,
}
}

namespace {

// The values a policy's `scope` rule names, gathered without repeating one.
// The views point into the configuration, which outlives the document built
// from them
auto collect_scope_rule(const sourcemeta::core::JSON &claims,
std::vector<std::string_view> &result) -> void {
if (!claims.is_object()) {
return;
}

const auto *rule{claims.try_at("scope")};
if (rule == nullptr || !rule->is_object()) {
return;
}

const auto *values{rule->try_at("values")};
if (values == nullptr || !values->is_array()) {
return;
}

for (const auto &value : values->as_array()) {
if (!value.is_string()) {
continue;
}

const std::string_view entry{value.to_string()};
if (std::ranges::find(result, entry) == result.cend()) {
result.push_back(entry);
}
}
}

} // namespace

auto mcp_resource_identifier(
const sourcemeta::one::Configuration &configuration,
const std::string_view endpoint) -> std::string {
Expand All @@ -188,6 +223,11 @@ auto generate_protected_resource_metadata(
// would mint one this instance refuses. Only an issuer whose policy accepts
// that audience can be named without sending the client into a rejection
std::vector<std::string_view> servers;
// RFC 9728 Section 2 gives these as the scope values used to request access
// to this resource, which is exactly what a policy's `scope` rule names. A
// client reading them learns what to ask its provider for, rather than
// discovering it by being refused
std::vector<std::string_view> scopes;
for (const auto index : authentication.governing(
sourcemeta::one::Authentication::Path::relative(endpoint))) {
assert(index < configuration.authentication.size());
Expand All @@ -210,17 +250,22 @@ auto generate_protected_resource_metadata(
if (std::ranges::find(servers, entry.issuer) == servers.cend()) {
servers.emplace_back(entry.issuer);
}

collect_scope_rule(entry.claims, scopes);
}

if (servers.empty()) {
return;
}

std::ranges::sort(scopes);

static constexpr std::array<std::string_view, 1> BEARER_METHODS{{"header"}};
sourcemeta::core::OAuthResourceMetadataConfig config;
config.resource = resource;
config.authorization_servers = servers;
config.bearer_methods_supported = BEARER_METHODS;
config.scopes_supported = scopes;

// The builder refuses anything a client could not use, which includes a
// resource identifier that is not an https URL. RFC 9728 Section 1.2 makes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,19 @@
#include <sourcemeta/one/http.h>
#include <sourcemeta/one/router.h>

#include <algorithm> // std::ranges::find, std::ranges::sort
#include <chrono> // std::chrono::seconds, std::chrono::system_clock
#include <filesystem> // std::filesystem::path
#include <functional> // std::less
#include <mutex> // std::mutex, std::scoped_lock
#include <optional> // std::optional, std::nullopt
#include <set> // std::set
#include <span> // std::span
#include <sstream> // std::ostringstream
#include <string> // std::string
#include <string_view> // std::string_view
#include <utility> // std::move
#include <vector> // std::vector

class ActionAuthLogin_v1 : public sourcemeta::one::RouterAction {
public:
Expand Down Expand Up @@ -214,6 +219,7 @@ class ActionAuthLogin_v1 : public sourcemeta::one::RouterAction {
}

this->requested_scope(wanted, scope_request);
this->report_unadvertised_claims(wanted, endpoints.value(), policy_name);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

const auto challenge{sourcemeta::core::oauth_pkce_challenge(verifier)};
sourcemeta::core::OIDCAuthenticationRequest authentication_request{};
Expand Down Expand Up @@ -330,6 +336,52 @@ class ActionAuthLogin_v1 : public sourcemeta::one::RouterAction {
}
}

// A rule naming a claim the provider never sends is one that can only ever
// deny, and nothing in the exchange would say so: the login succeeds, the
// token arrives, and admission fails for a reason nobody can see. So the
// provider's own account of what it may supply is compared against what the
// rules ask for, and a gap is named where an operator will find it.
//
// This reports and never refuses. OpenID Connect Discovery Section 3 says
// the list "might not be an exhaustive list", so a claim missing from it is
// a hint rather than a verdict, and a provider publishing no list at all is
// saying nothing rather than saying no
static auto report_unadvertised_claims(
const std::vector<sourcemeta::core::OIDCClaimRequest> &wanted,
const sourcemeta::one::Authentication::ProviderEndpoints &endpoints,
const std::string_view policy_name) -> void {
if (endpoints.claims_supported.empty()) {
return;
}

// Anybody at all may start a login, so saying this on every attempt would
// leave a stranger able to bury everything else in the log. What it says
// concerns a policy and its provider rather than the attempt that
// surfaced it, so saying it once says all of it
static std::mutex mutex;
static std::set<std::string, std::less<>> reported;

for (const auto &claim : wanted) {
if (std::ranges::find(endpoints.claims_supported, claim.name) !=
endpoints.claims_supported.cend()) {
continue;
}

std::string subject{claim.name};
subject += " of the policy ";
subject += policy_name;
const std::scoped_lock guard{mutex};
if (!reported.insert(subject).second) {
continue;
}

sourcemeta::one::HTTP_LOG(
"The provider does not advertise a claim a rule requires, so the "
"rule may never match. The claim is",
subject);
}
}

// Every reason a login cannot start answers identically. The login page names
// its policies to anybody who reaches a gated path, so which policies exist
// is published rather than secret, but whether one is misconfigured and
Expand Down
5 changes: 5 additions & 0 deletions src/authentication/include/sourcemeta/one/authentication.h
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,11 @@ class SOURCEMETA_ONE_AUTHENTICATION_EXPORT Authentication {
// Whether the provider honours the claims request parameter, which is the
// standard way to ask for a claim no standard scope carries
bool claims_parameter_supported{false};
// The claims the provider says it may be able to supply. OpenID Connect
// Discovery Section 3 calls this list non-exhaustive, so a claim missing
// from it is worth reporting and never worth refusing over, and a provider
// publishing none says nothing at all
std::vector<std::string> claims_supported{};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: This member changes the binary layout of the public ProviderEndpoints value returned by Authentication::endpoints, so applications built against the previous 6.x header can suffer memory corruption when loaded with the new 6.x library. Preserving the layout with a separate accessor/opaque result, or making this an ABI-major release, would avoid that compatibility break.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/authentication/include/sourcemeta/one/authentication.h, line 263:

<comment>This member changes the binary layout of the public `ProviderEndpoints` value returned by `Authentication::endpoints`, so applications built against the previous 6.x header can suffer memory corruption when loaded with the new 6.x library. Preserving the layout with a separate accessor/opaque result, or making this an ABI-major release, would avoid that compatibility break.</comment>

<file context>
@@ -256,6 +256,11 @@ class SOURCEMETA_ONE_AUTHENTICATION_EXPORT Authentication {
+    // Discovery Section 3 calls this list non-exhaustive, so a claim missing
+    // from it is worth reporting and never worth refusing over, and a provider
+    // publishing none says nothing at all
+    std::vector<std::string> claims_supported{};
   };
 
</file context>

};

// What the named interactive policy's provider says about itself, retrieved
Expand Down
22 changes: 22 additions & 0 deletions src/configuration/schema/configuration.json
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,17 @@
},
"minLength": 1
},
"properties": {
"scope": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[\\x21\\x23-\\x5B\\x5D-\\x7E]+$"
}
}
},
"additionalProperties": {
"type": "array",
"minItems": 1,
Expand Down Expand Up @@ -260,6 +271,17 @@
},
"minLength": 1
},
"properties": {
"scope": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"pattern": "^[\\x21\\x23-\\x5B\\x5D-\\x7E]+$"
}
}
},
"additionalProperties": {
"type": "array",
"minItems": 1,
Expand Down
12 changes: 12 additions & 0 deletions src/self/v1/schemas/mcp/prm/response.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
{
"resource": "https://schemas.example.com/self/v1/mcp",
"authorization_servers": [ "https://login.example.com" ],
"scopes_supported": [ "mcp:invoke" ],
"bearer_methods_supported": [ "header" ]
}
],
Expand Down Expand Up @@ -33,6 +34,17 @@
"format": "uri"
}
},
"scopes_supported": {
"description": "The scope values used in authorization requests to request access to this endpoint, taken from the claim rules of the policies that gate it. Absent where no policy names one, since RFC 9728 admits no empty array here",
"type": "array",

@augmentcode augmentcode Bot Aug 3, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

src/self/v1/schemas/mcp/prm/response.json:39: scopes_supported currently permits an empty array, but RFC 9728 §3.2 requires zero-value arrays to be omitted (and the core metadata validator treats empty scopes_supported as malformed). Tightening the schema here (e.g., requiring at least one item) would better match the spec/implementation contract.

Severity: low

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

"minItems": 1,
"uniqueItems": true,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
"items": {
"description": "A single scope token, whose character set RFC 6749 Section 3.3 defines to exclude the space that separates them, along with the quote and backslash",
"type": "string",
"pattern": "^[\\x21\\x23-\\x5B\\x5D-\\x7E]+$"
}
},
"bearer_methods_supported": {
"description": "How a token is presented, which here is only the Authorization header",
"const": [ "header" ]
Expand Down
Loading
Loading