diff --git a/enterprise/authentication/authentication.cc b/enterprise/authentication/authentication.cc index fc4a5bf6e..1e7dedec0 100644 --- a/enterprise/authentication/authentication.cc +++ b/enterprise/authentication/authentication.cc @@ -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); } diff --git a/enterprise/e2e/auth-mcp-path/hurl/mcp-prm.all.hurl b/enterprise/e2e/auth-mcp-path/hurl/mcp-prm.all.hurl index c9d5a2d7d..66ea11333 100644 --- a/enterprise/e2e/auth-mcp-path/hurl/mcp-prm.all.hurl +++ b/enterprise/e2e/auth-mcp-path/hurl/mcp-prm.all.hurl @@ -64,6 +64,7 @@ document_schema: header "Link" regex "]+)>" { "resource": "{{base}}/registry/self/v1/mcp", "authorization_servers": [ "https://accounts.example.com" ], + "scopes_supported": [ "mcp:invoke", "mcp:read" ], "bearer_methods_supported": [ "header" ] } @@ -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" ] } diff --git a/enterprise/e2e/auth-mcp-path/one.json b/enterprise/e2e/auth-mcp-path/one.json index 2bfca0fae..618afd230 100644 --- a/enterprise/e2e/auth-mcp-path/one.json +++ b/enterprise/e2e/auth-mcp-path/one.json @@ -15,7 +15,13 @@ "audience": "https://registry:8000/registry/self/v1/mcp", "algorithms": [ "RS256" - ] + ], + "claims": { + "scope": [ + "mcp:invoke", + "mcp:read" + ] + } } ], "contents": { diff --git a/enterprise/e2e/auth/hurl/mcp-resources.all.hurl b/enterprise/e2e/auth/hurl/mcp-resources.all.hurl index 7c04aff75..7fd585ca3 100644 --- a/enterprise/e2e/auth/hurl/mcp-resources.all.hurl +++ b/enterprise/e2e/auth/hurl/mcp-resources.all.hurl @@ -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" @@ -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" @@ -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" diff --git a/enterprise/e2e/html/hurl/mcp-2025-11-25-gzip.all.hurl b/enterprise/e2e/html/hurl/mcp-2025-11-25-gzip.all.hurl index 44dc81d12..b4bfccdd8 100644 --- a/enterprise/e2e/html/hurl/mcp-2025-11-25-gzip.all.hurl +++ b/enterprise/e2e/html/hurl/mcp-2025-11-25-gzip.all.hurl @@ -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" @@ -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" diff --git a/enterprise/e2e/html/hurl/mcp-2025-11-25-resources.all.hurl b/enterprise/e2e/html/hurl/mcp-2025-11-25-resources.all.hurl index a7379d007..5f76794ec 100644 --- a/enterprise/e2e/html/hurl/mcp-2025-11-25-resources.all.hurl +++ b/enterprise/e2e/html/hurl/mcp-2025-11-25-resources.all.hurl @@ -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" diff --git a/enterprise/e2e/path/hurl/mcp-2025-03-26.all.hurl b/enterprise/e2e/path/hurl/mcp-2025-03-26.all.hurl index 828d1a69f..2c1c4fa3a 100644 --- a/enterprise/e2e/path/hurl/mcp-2025-03-26.all.hurl +++ b/enterprise/e2e/path/hurl/mcp-2025-03-26.all.hurl @@ -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" diff --git a/enterprise/e2e/path/hurl/mcp-2025-06-18.all.hurl b/enterprise/e2e/path/hurl/mcp-2025-06-18.all.hurl index 503d0ba2f..85a1c3c4e 100644 --- a/enterprise/e2e/path/hurl/mcp-2025-06-18.all.hurl +++ b/enterprise/e2e/path/hurl/mcp-2025-06-18.all.hurl @@ -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" diff --git a/enterprise/e2e/path/hurl/mcp-2025-11-25-gzip.all.hurl b/enterprise/e2e/path/hurl/mcp-2025-11-25-gzip.all.hurl index 778ee1d50..f6cb2175c 100644 --- a/enterprise/e2e/path/hurl/mcp-2025-11-25-gzip.all.hurl +++ b/enterprise/e2e/path/hurl/mcp-2025-11-25-gzip.all.hurl @@ -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" @@ -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" diff --git a/enterprise/e2e/path/hurl/mcp-2025-11-25-resources.all.hurl b/enterprise/e2e/path/hurl/mcp-2025-11-25-resources.all.hurl index 9ef14cc64..f06e8c8cc 100644 --- a/enterprise/e2e/path/hurl/mcp-2025-11-25-resources.all.hurl +++ b/enterprise/e2e/path/hurl/mcp-2025-11-25-resources.all.hurl @@ -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" diff --git a/enterprise/index/enterprise_index.cc b/enterprise/index/enterprise_index.cc index fd04afbf1..2c48038ef 100644 --- a/enterprise/index/enterprise_index.cc +++ b/enterprise/index/enterprise_index.cc @@ -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 &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 { @@ -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 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 scopes; for (const auto index : authentication.governing( sourcemeta::one::Authentication::Path::relative(endpoint))) { assert(index < configuration.authentication.size()); @@ -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 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 diff --git a/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_login_v1.h b/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_login_v1.h index 375ad134b..8163be01c 100644 --- a/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_login_v1.h +++ b/enterprise/server/include/sourcemeta/one/enterprise_server_action_auth_login_v1.h @@ -14,14 +14,19 @@ #include #include +#include // std::ranges::find, std::ranges::sort #include // std::chrono::seconds, std::chrono::system_clock #include // std::filesystem::path +#include // std::less +#include // std::mutex, std::scoped_lock #include // std::optional, std::nullopt +#include // std::set #include // std::span #include // std::ostringstream #include // std::string #include // std::string_view #include // std::move +#include // std::vector class ActionAuthLogin_v1 : public sourcemeta::one::RouterAction { public: @@ -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); const auto challenge{sourcemeta::core::oauth_pkce_challenge(verifier)}; sourcemeta::core::OIDCAuthenticationRequest authentication_request{}; @@ -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 &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> 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 diff --git a/src/authentication/include/sourcemeta/one/authentication.h b/src/authentication/include/sourcemeta/one/authentication.h index e02a06c65..87fe600a8 100644 --- a/src/authentication/include/sourcemeta/one/authentication.h +++ b/src/authentication/include/sourcemeta/one/authentication.h @@ -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 claims_supported{}; }; // What the named interactive policy's provider says about itself, retrieved diff --git a/src/configuration/schema/configuration.json b/src/configuration/schema/configuration.json index 7cc78747e..5abb9bb9c 100644 --- a/src/configuration/schema/configuration.json +++ b/src/configuration/schema/configuration.json @@ -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, @@ -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, diff --git a/src/self/v1/schemas/mcp/prm/response.json b/src/self/v1/schemas/mcp/prm/response.json index dd58e9ac7..5daeda8bd 100644 --- a/src/self/v1/schemas/mcp/prm/response.json +++ b/src/self/v1/schemas/mcp/prm/response.json @@ -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" ] } ], @@ -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", + "minItems": 1, + "uniqueItems": true, + "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" ] diff --git a/test/unit/configuration/configuration.test.json b/test/unit/configuration/configuration.test.json index 64f0eb7a0..85647e374 100644 --- a/test/unit/configuration/configuration.test.json +++ b/test/unit/configuration/configuration.test.json @@ -486,6 +486,79 @@ ] } }, + { + "description": "A JWT authentication scope value carrying a separator is invalid", + "valid": false, + "data": { + "url": "http://localhost:8000", + "authentication": [ + { + "type": "jwt", + "name": "ci", + "paths": [ "/internal" ], + "issuer": "https://acme.example.com", + "audience": "https://schemas.example.com", + "algorithms": [ "RS256" ], + "claims": { "scope": [ "registry:read registry:write" ] } + } + ] + } + }, + { + "description": "A JWT authentication scope value carrying a quote is invalid", + "valid": false, + "data": { + "url": "http://localhost:8000", + "authentication": [ + { + "type": "jwt", + "name": "ci", + "paths": [ "/internal" ], + "issuer": "https://acme.example.com", + "audience": "https://schemas.example.com", + "algorithms": [ "RS256" ], + "claims": { "scope": [ "registry:\"read" ] } + } + ] + } + }, + { + "description": "An OIDC authentication scope value carrying a separator is invalid", + "valid": false, + "data": { + "url": "http://localhost:8000", + "authentication": [ + { + "type": "oidc", + "name": "corporate", + "paths": [ "/internal" ], + "issuer": "https://acme.example.com", + "clientId": "registry", + "clientSecret": { "environmentVariable": "ONE_SECRET" }, + "sessionSecrets": [ { "environmentVariable": "ONE_SESSION" } ], + "claims": { "scope": [ "one two" ] } + } + ] + } + }, + { + "description": "A claim other than the scope may carry a separator, since only that one is a set", + "valid": true, + "data": { + "url": "http://localhost:8000", + "authentication": [ + { + "type": "jwt", + "name": "ci", + "paths": [ "/internal" ], + "issuer": "https://acme.example.com", + "audience": "https://schemas.example.com", + "algorithms": [ "RS256" ], + "claims": { "department": [ "platform engineering" ] } + } + ] + } + }, { "description": "A JWT authentication policy naming a provider specific claim is valid", "valid": true,