diff --git a/src/core/http/include/sourcemeta/core/http_message.h b/src/core/http/include/sourcemeta/core/http_message.h index c5e87526a..2715e4c9b 100644 --- a/src/core/http/include/sourcemeta/core/http_message.h +++ b/src/core/http/include/sourcemeta/core/http_message.h @@ -151,21 +151,26 @@ inline auto http_parse_headers(const std::string_view input, Callback callback) /// with the name and value. A request cookie header carries only names and /// values, never attributes. Surrounding whitespace is trimmed, values are /// otherwise reported verbatim, and pairs that lack a `=` or have an empty name -/// are skipped. For example: +/// are skipped. Neither argument allocates, as both are borrowed from the +/// input, so anything the callback keeps must not outlive it. For example: /// /// ```cpp /// #include /// #include /// #include /// -/// std::string_view last_value; +/// std::size_t count{0}; /// sourcemeta::core::http_parse_cookies( /// "session=abc; theme=dark", -/// [&last_value](const std::string_view, const std::string_view value) { -/// last_value = value; +/// [&count](const std::string_view, const std::string_view) { +/// count += 1; /// }); -/// assert(last_value == "dark"); +/// assert(count == 2); /// ``` +/// +/// A header may carry several cookies under one name, so a callback that +/// assigns to a single variable keeps whichever happens to come last. Use +/// `http_cookie_values` to look one up by name. template requires std::invocable inline auto http_parse_cookies(const std::string_view input, Callback callback) @@ -199,9 +204,8 @@ inline auto http_parse_cookies(const std::string_view input, Callback callback) /// @ingroup http /// Parse the value of an RFC 6265 §4.2 `Cookie` request header, given without /// the field name, into any container of name and value pairs. The names and -/// values are forwarded as views that borrow from `input`, so a container of -/// `std::string_view` pairs collects them without copying, while a container of -/// `std::string` pairs owns them. For example: +/// values are borrowed from `input` rather than copied, so a container of views +/// must not outlive it, while an owning container may. For example: /// /// ```cpp /// #include @@ -229,6 +233,49 @@ inline auto http_parse_cookies(const std::string_view input, Container &cookies) }); } +/// @ingroup http +/// Collect every value carried under the given cookie name, in the order the +/// header presents them. A request may carry several cookies with one name, +/// since a parent domain and the host itself can each set one and RFC 6265 +/// §4.2.2 notes the server "cannot determine from the Cookie header alone [...] +/// for which hosts the cookie is valid". That section also warns that servers +/// "SHOULD NOT rely upon the order in which these cookies appear", so a caller +/// verifying a signed cookie tries every value rather than any single one. +/// Naming a cookie with the RFC 6265bis §4.1.3.2 `__Host-` prefix prevents the +/// collision at the source. +/// +/// The values are borrowed from `input` rather than copied, so a container of +/// views must not outlive it, while an owning container may. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// #include +/// +/// std::vector values; +/// sourcemeta::core::http_cookie_values("session=abc; theme=dark; session=xyz", +/// "session", values); +/// assert(values.size() == 2); +/// assert(values.at(0) == "abc"); +/// assert(values.at(1) == "xyz"); +/// ``` +template + requires requires(Container container, std::string_view entry) { + container.emplace_back(entry); + } +inline auto http_cookie_values(const std::string_view input, + const std::string_view name, Container &values) + -> void { + http_parse_cookies(input, + [&name, &values](const std::string_view cookie, + const std::string_view value) -> void { + if (cookie == name) { + values.emplace_back(value); + } + }); +} + /// @ingroup http /// Parse the field lines of a raw message header block, skipping the start /// line, into any container of name and value pairs, normalising names to diff --git a/src/core/http/include/sourcemeta/core/http_system.h b/src/core/http/include/sourcemeta/core/http_system.h index 6a04084a0..2198558ad 100644 --- a/src/core/http/include/sourcemeta/core/http_system.h +++ b/src/core/http/include/sourcemeta/core/http_system.h @@ -232,7 +232,10 @@ class SOURCEMETA_CORE_HTTP_EXPORT HTTPSystemRequest { -> HTTPSystemRequest &; /// Perform the request. A failure to obtain a response is reported as an - /// error, while unsuccessful status codes are returned on the result + /// error, while unsuccessful status codes are returned on the result. This + /// blocks the calling thread until the response arrives or a timeout elapses, + /// so it must not run on an event loop or any other thread serving unrelated + /// work. [[nodiscard]] auto send() const -> HTTPResponse; private: diff --git a/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h b/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h index a9c4529d9..1613d35dc 100644 --- a/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h +++ b/src/core/jose/include/sourcemeta/core/jose_jwks_provider.h @@ -92,15 +92,33 @@ class SOURCEMETA_CORE_JOSE_EXPORT JWKSProvider { /// keys as needed. Returns no value when the token is fully valid, otherwise /// the first failing step. The current time and the clock skew tolerance are /// the provider's own concern, so a caller supplies only what identifies the - /// token: an optional expected subject and an optional expected type for the - /// access token profile. + /// token: the expected subject, or no value to accept any, and the expected + /// `typ` header, or no value to accept any. + /// + /// Neither is defaulted. RFC 9068 Section 4 requires a resource server to + /// "verify that the `typ` header value is `at+jwt` or `application/at+jwt` + /// and reject tokens carrying any other value", and Section 5 explains that + /// this is what keeps an OpenID Connect ID Token from being accepted as an + /// access token. A default of no value would make skipping that check the + /// thing a caller gets for writing less, so the decision is spelled at the + /// call site instead. Prefer `verify_access_token` when the token is an + /// access token. [[nodiscard]] auto verify(const JWT &token, const std::span allowed_algorithms, const std::string_view expected_issuer, const std::string_view expected_audience, - const std::optional expected_subject = std::nullopt, - const std::optional expected_type = std::nullopt) + const std::optional expected_subject, + const std::optional expected_type) + -> std::optional; + + /// Verify a token as an RFC 9068 JWT access token, pinning the `typ` header + /// to `at+jwt` so the profile's Section 4 requirement cannot be omitted. + [[nodiscard]] auto verify_access_token( + const JWT &token, const std::span allowed_algorithms, + const std::string_view expected_issuer, + const std::string_view expected_audience, + const std::optional expected_subject = std::nullopt) -> std::optional; /// Verify a token exactly as the other overload does, additionally exposing diff --git a/src/core/jose/include/sourcemeta/core/jose_verify.h b/src/core/jose/include/sourcemeta/core/jose_verify.h index c2a0c7a77..45c3ed2de 100644 --- a/src/core/jose/include/sourcemeta/core/jose_verify.h +++ b/src/core/jose/include/sourcemeta/core/jose_verify.h @@ -244,15 +244,14 @@ enum class JWTVerificationError : std::uint8_t { /// assert(error.has_value()); /// ``` SOURCEMETA_CORE_JOSE_EXPORT -auto jwt_verify( - const JWT &token, const JWKS &keys, - const std::span allowed_algorithms, - const std::string_view expected_issuer, - const std::string_view expected_audience, - const std::chrono::system_clock::time_point now, - const JWTClockSkew clock_skew = {}, - const std::optional expected_subject = std::nullopt, - const std::optional expected_type = std::nullopt) +auto jwt_verify(const JWT &token, const JWKS &keys, + const std::span allowed_algorithms, + const std::string_view expected_issuer, + const std::string_view expected_audience, + const std::chrono::system_clock::time_point now, + const JWTClockSkew clock_skew, + const std::optional expected_subject, + const std::optional expected_type) -> std::optional; } // namespace sourcemeta::core diff --git a/src/core/jose/jose_jwks_provider.cc b/src/core/jose/jose_jwks_provider.cc index d2293c3e3..528d4051e 100644 --- a/src/core/jose/jose_jwks_provider.cc +++ b/src/core/jose/jose_jwks_provider.cc @@ -134,6 +134,20 @@ auto JWKSProvider::verify( this->clock_()); } +auto JWKSProvider::verify_access_token( + const JWT &token, const std::span allowed_algorithms, + const std::string_view expected_issuer, + const std::string_view expected_audience, + const std::optional expected_subject) + -> std::optional { + // RFC 9068 Section 2.1: "it is RECOMMENDED that the "application/" prefix be + // omitted. Therefore, the "typ" value used SHOULD be "at+jwt"". The + // comparison accepts either spelling, so pinning the short one satisfies + // Section 4 + return this->verify(token, allowed_algorithms, expected_issuer, + expected_audience, expected_subject, "at+jwt"); +} + auto JWKSProvider::verify( const JWT &token, const std::span allowed_algorithms, const std::string_view expected_issuer, diff --git a/src/core/jose/jose_jwt_verify.cc b/src/core/jose/jose_jwt_verify.cc index 448b07f2d..b1dc8d4cf 100644 --- a/src/core/jose/jose_jwt_verify.cc +++ b/src/core/jose/jose_jwt_verify.cc @@ -44,8 +44,10 @@ auto jwt_verify(const JWT &token, const JWKS &keys, const std::optional expected_subject, const std::optional expected_type) -> std::optional { - // The algorithm allow-list is enforced before any key is touched, per step 3 - // of the Sourcemeta One validation algorithm + // RFC 8725 Section 3.1: "Libraries MUST enable the caller to specify a + // supported set of algorithms and MUST NOT use any other algorithms when + // performing cryptographic operations", so the allow-list is enforced before + // any key is touched const auto algorithm{token.algorithm()}; if (!algorithm.has_value() || std::ranges::find(allowed_algorithms, algorithm.value()) == diff --git a/src/core/oauth/include/sourcemeta/core/oauth_metadata.h b/src/core/oauth/include/sourcemeta/core/oauth_metadata.h index d21aaf407..5ad13bc58 100644 --- a/src/core/oauth/include/sourcemeta/core/oauth_metadata.h +++ b/src/core/oauth/include/sourcemeta/core/oauth_metadata.h @@ -60,6 +60,25 @@ auto oauth_well_known_url(const std::string_view identifier, const OAuthWellKnownKind kind, std::string &sink) -> bool; +/// @ingroup oauth +/// Whether a URL is usable as an endpoint a client sends requests to: the https +/// scheme, compared case-insensitively per RFC 3986 Section 3.1, a non-empty +/// host, and no fragment, which RFC 6749 Section 3.1 forbids on an endpoint. A +/// query is permitted. This is the rule the metadata parsers apply, named for +/// the endpoint case rather than as a general https test, since the fragment +/// prohibition comes from the endpoint specifications and does not hold of +/// https URLs at large. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_is_endpoint_url("https://example.com/token")); +/// assert(!sourcemeta::core::oauth_is_endpoint_url("http://example.com/token")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_is_endpoint_url(const std::string_view value) -> bool; + #if defined(_MSC_VER) #pragma warning(disable : 4251) #endif diff --git a/src/core/oauth/oauth_metadata.cc b/src/core/oauth/oauth_metadata.cc index 0e8a126d0..06e505d22 100644 --- a/src/core/oauth/oauth_metadata.cc +++ b/src/core/oauth/oauth_metadata.cc @@ -237,6 +237,23 @@ auto validated_resource_metadata(JSON &&data, const std::string_view resource) } // namespace +// RFC 6749 Section 3.1: "the authorization server MUST require the use of TLS +// as described in Section 1.6 when sending requests to the authorization +// endpoint", which over HTTP means the https scheme, and "The endpoint URI MUST +// NOT include a fragment component", while it "MAY include an +// "application/x-www-form-urlencoded" formatted (per Appendix B) query +// component". Unlike an identifier, which RFC 8414 Section 4 compares as "a +// Unicode code-point-to-code-point equality comparison", an endpoint is a +// location to dereference, so RFC 3986 Section 3.1 governs and makes its scheme +// case-insensitive +auto oauth_is_endpoint_url(const std::string_view value) -> bool { + const auto uri{oauth_try_parse_uri(value)}; + return uri.has_value() && uri->scheme().has_value() && + equals_ignore_case(uri->scheme().value(), "https") && + uri->host().has_value() && !uri->host().value().empty() && + !uri->fragment().has_value(); +} + auto oauth_well_known_url(const std::string_view identifier, const OAuthWellKnownKind kind, std::string &sink) -> bool { diff --git a/src/core/oauth/oauth_registration.cc b/src/core/oauth/oauth_registration.cc index 9e44d0066..07e3563e2 100644 --- a/src/core/oauth/oauth_registration.cc +++ b/src/core/oauth/oauth_registration.cc @@ -2,6 +2,7 @@ #include #include +#include #include "oauth_json.h" #include "oauth_syntax.h" diff --git a/src/core/oauth/oauth_syntax.h b/src/core/oauth/oauth_syntax.h index d872dd92f..d80b092d4 100644 --- a/src/core/oauth/oauth_syntax.h +++ b/src/core/oauth/oauth_syntax.h @@ -60,23 +60,6 @@ inline auto oauth_is_advertised_issuer(const std::string_view value) -> bool { !uri->query().has_value() && !uri->fragment().has_value(); } -// RFC 6749 Section 3.1: "the authorization server MUST require the use of TLS -// as described in Section 1.6 when sending requests to the authorization -// endpoint", which over HTTP means the https scheme, and "The endpoint URI MUST -// NOT include a fragment component", while it "MAY include an -// "application/x-www-form-urlencoded" formatted (per Appendix B) query -// component". Unlike an identifier, which RFC 8414 Section 4 compares as "a -// Unicode code-point-to-code-point equality comparison", an endpoint is a -// location to dereference, so RFC 3986 Section 3.1 governs and makes its scheme -// case-insensitive -inline auto oauth_is_endpoint_url(const std::string_view value) -> bool { - const auto uri{oauth_try_parse_uri(value)}; - return uri.has_value() && uri->scheme().has_value() && - equals_ignore_case(uri->scheme().value(), "https") && - uri->host().has_value() && !uri->host().value().empty() && - !uri->fragment().has_value(); -} - // RFC 3986 Section 2.3: "unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"", // the character set RFC 7636 reuses for the PKCE verifier and challenge inline auto oauth_is_unreserved(const char character) noexcept -> bool { diff --git a/src/core/oidc/include/sourcemeta/core/oidc_metadata.h b/src/core/oidc/include/sourcemeta/core/oidc_metadata.h index a91175489..c9560e730 100644 --- a/src/core/oidc/include/sourcemeta/core/oidc_metadata.h +++ b/src/core/oidc/include/sourcemeta/core/oidc_metadata.h @@ -52,11 +52,44 @@ class SOURCEMETA_CORE_OIDC_EXPORT OIDCProviderMetadata { /// throwing when it is invalid. The document is moved in. OIDCProviderMetadata(JSON &&data, const std::string_view issuer); + /// Apply the OpenID Connect layer to a document already parsed and validated + /// as OAuth authorization server metadata, throwing when the OpenID Connect + /// requirements are not met. The document is moved in and the OAuth checks + /// are not repeated. + explicit OIDCProviderMetadata(OAuthServerMetadata &&oauth); + /// Construct and validate a metadata document for an expected issuer, /// returning no value when it is invalid. The document is moved in. [[nodiscard]] static auto from(JSON &&data, const std::string_view issuer) -> std::optional; + /// Apply the OpenID Connect layer to a document already parsed and validated + /// as OAuth authorization server metadata, returning no value when the + /// OpenID Connect requirements are not met. This is what a caching resolver + /// hands back, so lifting it costs no reparse and no repeated OAuth + /// validation. For example: + /// + /// ```cpp + /// #include + /// #include + /// + /// auto document{sourcemeta::core::parse_json(R"JSON({ + /// "issuer":"https://example.com", + /// "jwks_uri":"https://example.com/jwks", + /// "response_types_supported":[ "code" ], + /// "subject_types_supported":[ "public" ], + /// "id_token_signing_alg_values_supported":[ "RS256" ] + /// })JSON")}; + /// auto oauth{sourcemeta::core::OAuthServerMetadata::from( + /// std::move(document), "https://example.com")}; + /// assert(oauth.has_value()); + /// const auto metadata{ + /// sourcemeta::core::OIDCProviderMetadata::from(std::move(oauth).value())}; + /// assert(metadata.has_value()); + /// ``` + [[nodiscard]] static auto from(OAuthServerMetadata &&oauth) + -> std::optional; + /// The issuer identifier (OpenID Connect Discovery 1.0 Section 3). [[nodiscard]] auto issuer() const -> std::string_view; diff --git a/src/core/oidc/oidc_id_token.cc b/src/core/oidc/oidc_id_token.cc index 8940f077e..4b031b69d 100644 --- a/src/core/oidc/oidc_id_token.cc +++ b/src/core/oidc/oidc_id_token.cc @@ -236,7 +236,8 @@ auto oidc_validate_id_token( // audience, expiration, not-before, and issued-at (OpenID Connect Core 1.0 // Section 3.1.3.7 steps 6 through 9) const auto error{jwt_verify(token, keys, allowed_algorithms, issuer, - client_id, now, clock_skew)}; + client_id, now, clock_skew, std::nullopt, + std::nullopt)}; if (error.has_value()) { return std::nullopt; } diff --git a/src/core/oidc/oidc_metadata.cc b/src/core/oidc/oidc_metadata.cc index d4af55196..dfca05a06 100644 --- a/src/core/oidc/oidc_metadata.cc +++ b/src/core/oidc/oidc_metadata.cc @@ -164,6 +164,22 @@ OIDCProviderMetadata::OIDCProviderMetadata(JSON &&data, validate_provider_metadata(this->oauth_); } +OIDCProviderMetadata::OIDCProviderMetadata(OAuthServerMetadata &&oauth) + : oauth_{std::move(oauth)} { + validate_provider_metadata(this->oauth_); +} + +auto OIDCProviderMetadata::from(OAuthServerMetadata &&oauth) + -> std::optional { + // The OAuth layer validated itself on the way in, so only the OpenID Connect + // requirements can fail here + try { + return OIDCProviderMetadata{std::move(oauth)}; + } catch (const OIDCMetadataParseError &) { + return std::nullopt; + } +} + auto OIDCProviderMetadata::from(JSON &&data, const std::string_view issuer) -> std::optional { try { diff --git a/test/http/CMakeLists.txt b/test/http/CMakeLists.txt index 33fcdc368..76485696e 100644 --- a/test/http/CMakeLists.txt +++ b/test/http/CMakeLists.txt @@ -10,6 +10,7 @@ sourcemeta_test(NAMESPACE sourcemeta PROJECT core NAME http http_header_find_test.cc http_error_test.cc http_parse_bearer_test.cc http_cache_control_max_age_test.cc http_serialize_cookie_test.cc http_parse_cookies_test.cc + http_cookie_values_test.cc http_cookie_valid_test.cc http_aws_sigv4_test.cc http_syntax_test.cc http_system_request_test.cc) diff --git a/test/http/http_cookie_values_test.cc b/test/http/http_cookie_values_test.cc new file mode 100644 index 000000000..71c1cdec4 --- /dev/null +++ b/test/http/http_cookie_values_test.cc @@ -0,0 +1,98 @@ +#include +#include + +#include // std::string +#include // std::string_view +#include // std::vector + +TEST(collects_a_single_value) { + std::vector values; + sourcemeta::core::http_cookie_values("session=abc; theme=dark", "session", + values); + EXPECT_EQ(values.size(), std::size_t{1}); + EXPECT_EQ(values.at(0), "abc"); +} + +TEST(collects_every_value_under_one_name_in_order) { + // The shadowing case: a parent domain and the host each set one, and the + // header presents both with no indication of origin + std::vector values; + sourcemeta::core::http_cookie_values("session=abc; theme=dark; session=xyz", + "session", values); + EXPECT_EQ(values.size(), std::size_t{2}); + EXPECT_EQ(values.at(0), "abc"); + EXPECT_EQ(values.at(1), "xyz"); +} + +TEST(collects_three_values_under_one_name) { + std::vector values; + sourcemeta::core::http_cookie_values("a=1; a=2; a=3", "a", values); + EXPECT_EQ(values.size(), std::size_t{3}); + EXPECT_EQ(values.at(0), "1"); + EXPECT_EQ(values.at(1), "2"); + EXPECT_EQ(values.at(2), "3"); +} + +TEST(collects_nothing_for_an_absent_name) { + std::vector values; + sourcemeta::core::http_cookie_values("session=abc; theme=dark", "missing", + values); + EXPECT_TRUE(values.empty()); +} + +TEST(collects_nothing_from_an_empty_header) { + std::vector values; + sourcemeta::core::http_cookie_values("", "session", values); + EXPECT_TRUE(values.empty()); +} + +TEST(matches_the_name_exactly) { + // RFC 6265 cookie names are case-sensitive, unlike field names + std::vector values; + sourcemeta::core::http_cookie_values("Session=abc; session=xyz", "session", + values); + EXPECT_EQ(values.size(), std::size_t{1}); + EXPECT_EQ(values.at(0), "xyz"); +} + +TEST(does_not_match_a_name_prefix) { + std::vector values; + sourcemeta::core::http_cookie_values("session_id=abc", "session", values); + EXPECT_TRUE(values.empty()); +} + +TEST(trims_surrounding_whitespace) { + std::vector values; + sourcemeta::core::http_cookie_values(" session = abc ; session = xyz ", + "session", values); + EXPECT_EQ(values.size(), std::size_t{2}); + EXPECT_EQ(values.at(0), "abc"); + EXPECT_EQ(values.at(1), "xyz"); +} + +TEST(collects_an_empty_value) { + std::vector values; + sourcemeta::core::http_cookie_values("session=; session=xyz", "session", + values); + EXPECT_EQ(values.size(), std::size_t{2}); + EXPECT_EQ(values.at(0), ""); + EXPECT_EQ(values.at(1), "xyz"); +} + +TEST(collects_into_an_owning_container) { + std::vector values; + sourcemeta::core::http_cookie_values("session=abc; session=xyz", "session", + values); + EXPECT_EQ(values.size(), std::size_t{2}); + EXPECT_EQ(values.at(0), "abc"); + EXPECT_EQ(values.at(1), "xyz"); +} + +TEST(appends_to_an_existing_container) { + std::vector values; + values.emplace_back("existing"); + sourcemeta::core::http_cookie_values("session=abc", "session", values); + EXPECT_EQ(values.size(), std::size_t{2}); + EXPECT_EQ(values.at(0), "existing"); + EXPECT_EQ(values.at(1), "abc"); +} diff --git a/test/jose/jose_jwks_provider_test.cc b/test/jose/jose_jwks_provider_test.cc index ca17747b9..930dc4bd4 100644 --- a/test/jose/jose_jwks_provider_test.cc +++ b/test/jose/jose_jwks_provider_test.cc @@ -4,7 +4,9 @@ #include // std::array #include // std::chrono #include // std::size_t +#include // std::int64_t #include // std::optional, std::nullopt +#include // std::ostringstream #include // std::runtime_error #include // std::string #include // std::string_view @@ -53,6 +55,54 @@ constexpr std::string_view ELLIPTIC_CURVE_KEYS{ constexpr std::array ALLOWED_RS256{ {sourcemeta::core::JWSAlgorithm::RS256}}; +// A symmetric key, so a test can mint tokens differing only in their `typ` +// header and observe which entry point admits which +constexpr std::string_view OCT_JWK{R"JSON({ + "kty": "oct", + "k": "AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow" +})JSON"}; + +constexpr std::array ALLOWED_HS256{ + {sourcemeta::core::JWSAlgorithm::HS256}}; + +auto oct_key_document() -> std::string { + auto keys{sourcemeta::core::JSON::make_array()}; + keys.push_back(sourcemeta::core::parse_json(OCT_JWK)); + auto document{sourcemeta::core::JSON::make_object()}; + document.assign("keys", std::move(keys)); + std::ostringstream stream; + sourcemeta::core::stringify(document, stream); + return stream.str(); +} + +auto sign_with_type(const std::string_view type) -> std::string { + auto header{sourcemeta::core::JSON::make_object()}; + header.assign("alg", sourcemeta::core::JSON{"HS256"}); + if (!type.empty()) { + header.assign("typ", sourcemeta::core::JSON{type}); + } + + auto payload{sourcemeta::core::JSON::make_object()}; + payload.assign("iss", sourcemeta::core::JSON{"acme"}); + payload.assign("aud", sourcemeta::core::JSON{"client"}); + payload.assign("exp", sourcemeta::core::JSON{std::int64_t{2000000000}}); + return sourcemeta::core::jwt_sign(header, payload, + sourcemeta::core::JWKPrivate::from( + sourcemeta::core::parse_json(OCT_JWK)) + .value()) + .value(); +} + +auto oct_provider() -> sourcemeta::core::JWKSProvider { + return sourcemeta::core::JWKSProvider{ + "https://issuer.test/jwks", + [](const std::string_view) + -> std::optional { + return sourcemeta::core::JWKSProvider::FetchResult{oct_key_document(), + std::nullopt}; + }}; +} + } // namespace TEST(unreachable_issuer_denies) { @@ -70,8 +120,8 @@ TEST(unreachable_issuer_denies) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - const auto error{ - provider.verify(token.value(), ALLOWED_RS256, "acme", "client")}; + const auto error{provider.verify(token.value(), ALLOWED_RS256, "acme", + "client", std::nullopt, std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::UnknownKey); EXPECT_EQ(calls, std::size_t{1}); @@ -91,8 +141,8 @@ TEST(malformed_body_is_treated_as_failure) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - const auto error{ - provider.verify(token.value(), ALLOWED_RS256, "acme", "client")}; + const auto error{provider.verify(token.value(), ALLOWED_RS256, "acme", + "client", std::nullopt, std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::UnknownKey); } @@ -113,11 +163,15 @@ TEST(cache_hit_does_not_refetch) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); } @@ -138,19 +192,25 @@ TEST(cache_control_absent_uses_fallback) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Still fresh one second before the 1 hour fallback lifetime elapses now = std::chrono::system_clock::from_time_t(1000000000 + 3599); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Stale at exactly the fallback lifetime now = std::chrono::system_clock::from_time_t(1000000000 + 3600); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{2}); } @@ -171,19 +231,25 @@ TEST(cache_control_above_maximum_clamps_down) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Still fresh one second before the 24 hour maximum lifetime elapses now = std::chrono::system_clock::from_time_t(1000000000 + 86399); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Stale at exactly the maximum lifetime, never at the advertised 48 hours now = std::chrono::system_clock::from_time_t(1000000000 + 86400); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{2}); } @@ -204,19 +270,25 @@ TEST(cache_control_below_minimum_clamps_up) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Still fresh one second before the 5 minute minimum lifetime elapses now = std::chrono::system_clock::from_time_t(1000000000 + 299); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Stale at exactly the minimum lifetime, never at the advertised 60 seconds now = std::chrono::system_clock::from_time_t(1000000000 + 300); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{2}); } @@ -238,20 +310,26 @@ TEST(cache_control_zero_clamps_to_minimum) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Still fresh one second before the 5 minute minimum lifetime elapses now = std::chrono::system_clock::from_time_t(1000000000 + 299); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Stale at exactly the minimum lifetime, and zero does not collapse into the // fallback now = std::chrono::system_clock::from_time_t(1000000000 + 300); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{2}); } @@ -275,8 +353,8 @@ TEST(unknown_key_triggers_one_guarded_refetch) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - const auto error{ - provider.verify(token.value(), ALLOWED_RS256, "acme", "client")}; + const auto error{provider.verify(token.value(), ALLOWED_RS256, "acme", + "client", std::nullopt, std::nullopt)}; EXPECT_FALSE(error.has_value()); EXPECT_EQ(calls, std::size_t{2}); } @@ -301,8 +379,8 @@ TEST(signature_failure_is_not_retried) { const auto token{sourcemeta::core::JWT::from(RS256_TOKEN_WITH_KEY_ID)}; EXPECT_TRUE(token.has_value()); - const auto error{ - provider.verify(token.value(), ALLOWED_RS256, "joe", "client")}; + const auto error{provider.verify(token.value(), ALLOWED_RS256, "joe", + "client", std::nullopt, std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::Signature); EXPECT_EQ(calls, std::size_t{1}); @@ -327,23 +405,23 @@ TEST(unknown_key_refetch_is_bounded_by_cooldown) { EXPECT_TRUE(token.has_value()); // Cold fetch then one guarded refetch, and the named key is still absent - const auto first{ - provider.verify(token.value(), ALLOWED_RS256, "joe", "client")}; + const auto first{provider.verify(token.value(), ALLOWED_RS256, "joe", + "client", std::nullopt, std::nullopt)}; EXPECT_TRUE(first.has_value()); EXPECT_EQ(first.value(), sourcemeta::core::JWTVerificationError::UnknownKey); EXPECT_EQ(calls, std::size_t{2}); // Within the cooldown window no further refetch happens - const auto second{ - provider.verify(token.value(), ALLOWED_RS256, "joe", "client")}; + const auto second{provider.verify(token.value(), ALLOWED_RS256, "joe", + "client", std::nullopt, std::nullopt)}; EXPECT_TRUE(second.has_value()); EXPECT_EQ(second.value(), sourcemeta::core::JWTVerificationError::UnknownKey); EXPECT_EQ(calls, std::size_t{2}); // After the cooldown elapses a new guarded refetch is allowed now = std::chrono::system_clock::from_time_t(1300000000 + 301); - const auto third{ - provider.verify(token.value(), ALLOWED_RS256, "joe", "client")}; + const auto third{provider.verify(token.value(), ALLOWED_RS256, "joe", + "client", std::nullopt, std::nullopt)}; EXPECT_TRUE(third.has_value()); EXPECT_EQ(third.value(), sourcemeta::core::JWTVerificationError::UnknownKey); EXPECT_EQ(calls, std::size_t{3}); @@ -369,21 +447,27 @@ TEST(failed_refresh_serves_stale_keys) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Past the fallback lifetime the refresh is attempted and fails, so the // retained keys still verify the token now = std::chrono::system_clock::from_time_t(1000000000 + 7200); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{2}); // A failed refresh backs off by the minimum lifetime, so an immediate retry // does not hammer the unreachable issuer now = std::chrono::system_clock::from_time_t(1000000000 + 7200 + 60); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{2}); } @@ -429,7 +513,9 @@ TEST(clock_skew_tolerates_recent_expiry) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); } @@ -448,8 +534,8 @@ TEST(fetcher_exception_is_treated_as_failure) { // A throwing fetcher must not escape verification, and it denies like any // failed fetch - const auto error{ - provider.verify(token.value(), ALLOWED_RS256, "acme", "client")}; + const auto error{provider.verify(token.value(), ALLOWED_RS256, "acme", + "client", std::nullopt, std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::UnknownKey); } @@ -474,13 +560,15 @@ TEST(fetcher_recovers_after_exception) { EXPECT_TRUE(token.has_value()); // The first fetch throws and denies, leaving the provider usable - const auto first{ - provider.verify(token.value(), ALLOWED_RS256, "acme", "client")}; + const auto first{provider.verify(token.value(), ALLOWED_RS256, "acme", + "client", std::nullopt, std::nullopt)}; EXPECT_TRUE(first.has_value()); EXPECT_EQ(first.value(), sourcemeta::core::JWTVerificationError::UnknownKey); // The next call obtains keys and verifies, proving no state was corrupted - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{2}); } @@ -504,14 +592,18 @@ TEST(fetcher_exception_serves_stale_keys) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Past the fallback lifetime the refresh throws, so the retained keys are // still served rather than failing closed now = std::chrono::system_clock::from_time_t(1000000000 + 7200); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{2}); } @@ -530,8 +622,8 @@ TEST(empty_key_set_is_treated_as_failure) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - const auto error{ - provider.verify(token.value(), ALLOWED_RS256, "acme", "client")}; + const auto error{provider.verify(token.value(), ALLOWED_RS256, "acme", + "client", std::nullopt, std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::UnknownKey); } @@ -555,7 +647,8 @@ TEST(disallowed_algorithm_denies) { // The token's signing algorithm is absent from the allow-list const std::array allowed{ {sourcemeta::core::JWSAlgorithm::ES256}}; - const auto error{provider.verify(token.value(), allowed, "acme", "client")}; + const auto error{provider.verify(token.value(), allowed, "acme", "client", + std::nullopt, std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::AlgorithmNotAllowed); @@ -578,8 +671,8 @@ TEST(expired_token_denies) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - const auto error{ - provider.verify(token.value(), ALLOWED_RS256, "acme", "client")}; + const auto error{provider.verify(token.value(), ALLOWED_RS256, "acme", + "client", std::nullopt, std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::Expiration); } @@ -598,8 +691,8 @@ TEST(wrong_audience_denies) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - const auto error{ - provider.verify(token.value(), ALLOWED_RS256, "acme", "unexpected")}; + const auto error{provider.verify(token.value(), ALLOWED_RS256, "acme", + "unexpected", std::nullopt, std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::Audience); } @@ -624,19 +717,25 @@ TEST(inverted_lifetime_bounds_stay_well_defined) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Still fresh one second before the minimum elapses now = std::chrono::system_clock::from_time_t(1000000000 + 86399); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{1}); // Stale at exactly the minimum now = std::chrono::system_clock::from_time_t(1000000000 + 86400); - EXPECT_FALSE(provider.verify(token.value(), ALLOWED_RS256, "acme", "client") + EXPECT_FALSE(provider + .verify(token.value(), ALLOWED_RS256, "acme", "client", + std::nullopt, std::nullopt) .has_value()); EXPECT_EQ(calls, std::size_t{2}); } @@ -657,8 +756,8 @@ TEST(empty_clock_falls_back_to_the_system_clock) { const auto token{sourcemeta::core::JWT::from(SIGNED_TOKEN)}; EXPECT_TRUE(token.has_value()); - const auto error{ - provider.verify(token.value(), ALLOWED_RS256, "acme", "client")}; + const auto error{provider.verify(token.value(), ALLOWED_RS256, "acme", + "client", std::nullopt, std::nullopt)}; EXPECT_FALSE(error.has_value()); } @@ -683,3 +782,64 @@ TEST(verify_shares_its_resolved_clock_reading) { EXPECT_FALSE(error.has_value()); EXPECT_TRUE(resolved_now == now); } + +TEST(verify_access_token_accepts_an_at_jwt_token) { + auto provider{oct_provider()}; + const auto compact{sign_with_type("at+jwt")}; + const auto token{sourcemeta::core::JWT::from(compact)}; + EXPECT_TRUE(token.has_value()); + EXPECT_FALSE( + provider + .verify_access_token(token.value(), ALLOWED_HS256, "acme", "client") + .has_value()); +} + +TEST(verify_access_token_accepts_the_prefixed_spelling) { + // RFC 9068 Section 4 admits "at+jwt" or "application/at+jwt" + auto provider{oct_provider()}; + const auto compact{sign_with_type("application/at+jwt")}; + const auto token{sourcemeta::core::JWT::from(compact)}; + EXPECT_TRUE(token.has_value()); + EXPECT_FALSE( + provider + .verify_access_token(token.value(), ALLOWED_HS256, "acme", "client") + .has_value()); +} + +TEST(verify_access_token_rejects_an_id_token) { + // An ID Token whose issuer and audience match is otherwise indistinguishable + // from an access token, so the type check is what refuses it (RFC 9068 + // Section 5) + auto provider{oct_provider()}; + const auto compact{sign_with_type("JWT")}; + const auto token{sourcemeta::core::JWT::from(compact)}; + EXPECT_TRUE(token.has_value()); + const auto permissive{provider.verify(token.value(), ALLOWED_HS256, "acme", + "client", std::nullopt, std::nullopt)}; + EXPECT_FALSE(permissive.has_value()); + const auto error{provider.verify_access_token(token.value(), ALLOWED_HS256, + "acme", "client")}; + EXPECT_TRUE(error.has_value()); + EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::Type); +} + +TEST(verify_access_token_rejects_a_token_without_a_type) { + auto provider{oct_provider()}; + const auto compact{sign_with_type("")}; + const auto token{sourcemeta::core::JWT::from(compact)}; + EXPECT_TRUE(token.has_value()); + const auto error{provider.verify_access_token(token.value(), ALLOWED_HS256, + "acme", "client")}; + EXPECT_TRUE(error.has_value()); + EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::Type); +} + +TEST(verify_access_token_still_checks_the_subject) { + auto provider{oct_provider()}; + const auto compact{sign_with_type("at+jwt")}; + const auto token{sourcemeta::core::JWT::from(compact)}; + EXPECT_TRUE(token.has_value()); + const auto error{provider.verify_access_token(token.value(), ALLOWED_HS256, + "acme", "client", "someone")}; + EXPECT_TRUE(error.has_value()); +} diff --git a/test/jose/jose_jwt_verify_test.cc b/test/jose/jose_jwt_verify_test.cc index 77c4d9ca4..2a7de6003 100644 --- a/test/jose/jose_jwt_verify_test.cc +++ b/test/jose/jose_jwt_verify_test.cc @@ -68,7 +68,8 @@ TEST(algorithm_not_allowed) { {sourcemeta::core::JWSAlgorithm::ES256}}; const auto error{sourcemeta::core::jwt_verify( token.value(), keys.value(), allowed, "joe", "client", - std::chrono::system_clock::from_time_t(1300000000))}; + std::chrono::system_clock::from_time_t(1300000000), {}, std::nullopt, + std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::AlgorithmNotAllowed); @@ -84,7 +85,8 @@ TEST(unknown_key_by_identifier) { {sourcemeta::core::JWSAlgorithm::RS256}}; const auto error{sourcemeta::core::jwt_verify( token.value(), keys.value(), allowed, "joe", "client", - std::chrono::system_clock::from_time_t(1300000000))}; + std::chrono::system_clock::from_time_t(1300000000), {}, std::nullopt, + std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::UnknownKey); } @@ -99,7 +101,8 @@ TEST(unknown_key_no_compatible_key) { {sourcemeta::core::JWSAlgorithm::RS256}}; const auto error{sourcemeta::core::jwt_verify( token.value(), keys.value(), allowed, "joe", "client", - std::chrono::system_clock::from_time_t(1300000000))}; + std::chrono::system_clock::from_time_t(1300000000), {}, std::nullopt, + std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::UnknownKey); } @@ -114,7 +117,8 @@ TEST(signature_failure) { {sourcemeta::core::JWSAlgorithm::RS256}}; const auto error{sourcemeta::core::jwt_verify( token.value(), keys.value(), allowed, "joe", "client", - std::chrono::system_clock::from_time_t(1300000000))}; + std::chrono::system_clock::from_time_t(1300000000), {}, std::nullopt, + std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::Signature); } @@ -129,7 +133,8 @@ TEST(valid_signature_reaches_audience_check) { {sourcemeta::core::JWSAlgorithm::RS256}}; const auto error{sourcemeta::core::jwt_verify( token.value(), keys.value(), allowed, "joe", "client", - std::chrono::system_clock::from_time_t(1300000000))}; + std::chrono::system_clock::from_time_t(1300000000), {}, std::nullopt, + std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::Audience); } @@ -144,7 +149,8 @@ TEST(issuer_mismatch_after_valid_signature) { {sourcemeta::core::JWSAlgorithm::RS256}}; const auto error{sourcemeta::core::jwt_verify( token.value(), keys.value(), allowed, "wrong", "client", - std::chrono::system_clock::from_time_t(1300000000))}; + std::chrono::system_clock::from_time_t(1300000000), {}, std::nullopt, + std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::Issuer); } @@ -178,7 +184,8 @@ TEST(valid) { {sourcemeta::core::JWSAlgorithm::RS256}}; const auto error{sourcemeta::core::jwt_verify( token.value(), keys.value(), allowed, "acme", "client", - std::chrono::system_clock::from_time_t(1000000000))}; + std::chrono::system_clock::from_time_t(1000000000), {}, std::nullopt, + std::nullopt)}; EXPECT_FALSE(error.has_value()); } @@ -248,7 +255,8 @@ TEST(jwt_verify_hs256_full_round_trip) { {sourcemeta::core::JWSAlgorithm::HS256}}; const auto error{sourcemeta::core::jwt_verify( token.value(), keys.value(), allowed, "acme", "client", - std::chrono::system_clock::from_time_t(1500000000))}; + std::chrono::system_clock::from_time_t(1500000000), {}, std::nullopt, + std::nullopt)}; EXPECT_FALSE(error.has_value()); } @@ -272,7 +280,8 @@ TEST(jwt_verify_hs256_rejected_when_not_allowed) { {sourcemeta::core::JWSAlgorithm::RS256}}; const auto error{sourcemeta::core::jwt_verify( token.value(), keys.value(), allowed, "acme", "client", - std::chrono::system_clock::from_time_t(1500000000))}; + std::chrono::system_clock::from_time_t(1500000000), {}, std::nullopt, + std::nullopt)}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), sourcemeta::core::JWTVerificationError::AlgorithmNotAllowed); diff --git a/test/oauth/oauth_metadata_test.cc b/test/oauth/oauth_metadata_test.cc index 855ae9835..64a30f2ea 100644 --- a/test/oauth/oauth_metadata_test.cc +++ b/test/oauth/oauth_metadata_test.cc @@ -1677,3 +1677,49 @@ TEST(make_server_metadata_rejects_a_non_https_jwks_uri) { const auto document{sourcemeta::core::oauth_make_server_metadata(config)}; EXPECT_FALSE(document.has_value()); } + +TEST(is_endpoint_url_accepts_an_https_url) { + EXPECT_TRUE(sourcemeta::core::oauth_is_endpoint_url("https://example.com")); + EXPECT_TRUE( + sourcemeta::core::oauth_is_endpoint_url("https://example.com/token")); +} + +TEST(is_endpoint_url_accepts_a_port_and_a_query) { + EXPECT_TRUE(sourcemeta::core::oauth_is_endpoint_url( + "https://example.com:8443/token?tenant=1")); +} + +TEST(is_endpoint_url_accepts_an_uppercase_scheme) { + EXPECT_TRUE(sourcemeta::core::oauth_is_endpoint_url("HTTPS://example.com")); + EXPECT_TRUE(sourcemeta::core::oauth_is_endpoint_url("HtTpS://example.com")); +} + +TEST(is_endpoint_url_rejects_a_cleartext_url) { + EXPECT_FALSE(sourcemeta::core::oauth_is_endpoint_url("http://example.com")); +} + +TEST(is_endpoint_url_rejects_a_fragment) { + EXPECT_FALSE( + sourcemeta::core::oauth_is_endpoint_url("https://example.com/token#a")); +} + +TEST(is_endpoint_url_rejects_a_missing_host) { + EXPECT_FALSE(sourcemeta::core::oauth_is_endpoint_url("https:///token")); + EXPECT_FALSE(sourcemeta::core::oauth_is_endpoint_url("https://:8443/token")); +} + +TEST(is_endpoint_url_rejects_a_relative_reference) { + EXPECT_FALSE(sourcemeta::core::oauth_is_endpoint_url("/token")); + EXPECT_FALSE(sourcemeta::core::oauth_is_endpoint_url("//example.com/token")); +} + +TEST(is_endpoint_url_rejects_another_scheme) { + EXPECT_FALSE(sourcemeta::core::oauth_is_endpoint_url("file://example.com/a")); + EXPECT_FALSE(sourcemeta::core::oauth_is_endpoint_url("javascript:alert(1)")); + EXPECT_FALSE(sourcemeta::core::oauth_is_endpoint_url("httpsx://example.com")); +} + +TEST(is_endpoint_url_rejects_a_malformed_url) { + EXPECT_FALSE(sourcemeta::core::oauth_is_endpoint_url("")); + EXPECT_FALSE(sourcemeta::core::oauth_is_endpoint_url("https://ex ample.com")); +} diff --git a/test/oidc/oidc_id_token_test.cc b/test/oidc/oidc_id_token_test.cc index 1530426dc..7205654ed 100644 --- a/test/oidc/oidc_id_token_test.cc +++ b/test/oidc/oidc_id_token_test.cc @@ -8,6 +8,7 @@ #include // std::int64_t #include // std::string #include // std::string_view +#include // std::move static constexpr std::string_view OCT_JWK{R"JSON({ "kty": "oct", @@ -21,10 +22,11 @@ static auto oct_private_key() -> sourcemeta::core::JWKPrivate { } static auto oct_key_set() -> sourcemeta::core::JWKS { - return sourcemeta::core::JWKS::from( - sourcemeta::core::parse_json(std::string{R"({ "keys": [ )"} + - std::string{OCT_JWK} + R"( ] })")) - .value(); + auto keys{sourcemeta::core::JSON::make_array()}; + keys.push_back(sourcemeta::core::parse_json(OCT_JWK)); + auto document{sourcemeta::core::JSON::make_object()}; + document.assign("keys", std::move(keys)); + return sourcemeta::core::JWKS::from(std::move(document)).value(); } static auto sign_id_token(const sourcemeta::core::JSON &payload) diff --git a/test/oidc/oidc_logout_test.cc b/test/oidc/oidc_logout_test.cc index 9390dc247..48f4a93c1 100644 --- a/test/oidc/oidc_logout_test.cc +++ b/test/oidc/oidc_logout_test.cc @@ -8,6 +8,7 @@ #include // std::int64_t #include // std::string #include // std::string_view +#include // std::move static constexpr std::string_view OCT_JWK{R"JSON({ "kty": "oct", @@ -15,10 +16,11 @@ static constexpr std::string_view OCT_JWK{R"JSON({ })JSON"}; static auto oct_key_set() -> sourcemeta::core::JWKS { - return sourcemeta::core::JWKS::from( - sourcemeta::core::parse_json(std::string{R"({ "keys": [ )"} + - std::string{OCT_JWK} + R"( ] })")) - .value(); + auto keys{sourcemeta::core::JSON::make_array()}; + keys.push_back(sourcemeta::core::parse_json(OCT_JWK)); + auto document{sourcemeta::core::JSON::make_object()}; + document.assign("keys", std::move(keys)); + return sourcemeta::core::JWKS::from(std::move(document)).value(); } static auto sign_logout_token(const std::string_view header, diff --git a/test/oidc/oidc_metadata_test.cc b/test/oidc/oidc_metadata_test.cc index 91bfbc676..252596a6d 100644 --- a/test/oidc/oidc_metadata_test.cc +++ b/test/oidc/oidc_metadata_test.cc @@ -681,3 +681,61 @@ TEST(make_accepts_a_mixed_case_https_scheme) { EXPECT_TRUE( sourcemeta::core::oidc_make_provider_metadata(config).has_value()); } + +TEST(from_lifts_validated_oauth_metadata) { + // The shape a caching resolver hands back, lifted without a reparse + auto oauth{sourcemeta::core::OAuthServerMetadata::from( + sourcemeta::core::JSON{VALID_PROVIDER_DOCUMENT}, "https://example.com")}; + EXPECT_TRUE(oauth.has_value()); + const auto metadata{ + sourcemeta::core::OIDCProviderMetadata::from(std::move(oauth).value())}; + EXPECT_TRUE(metadata.has_value()); + EXPECT_EQ(metadata.value().issuer(), "https://example.com"); + EXPECT_EQ(metadata.value().jwks_uri(), "https://example.com/jwks"); + EXPECT_EQ(metadata.value().userinfo_endpoint().value(), + "https://example.com/userinfo"); + EXPECT_TRUE(metadata.value().supports_subject_type("public")); +} + +TEST(from_oauth_rejects_a_document_missing_the_oidc_requirements) { + // Valid as OAuth authorization server metadata, but OpenID Connect promotes + // jwks_uri to REQUIRED and demands the subject and signing lists + auto document{sourcemeta::core::parse_json(R"JSON({ + "issuer": "https://example.com", + "response_types_supported": [ "code" ] + })JSON")}; + auto oauth{sourcemeta::core::OAuthServerMetadata::from( + std::move(document), "https://example.com")}; + EXPECT_TRUE(oauth.has_value()); + const auto metadata{ + sourcemeta::core::OIDCProviderMetadata::from(std::move(oauth).value())}; + EXPECT_FALSE(metadata.has_value()); +} + +TEST(from_oauth_rejects_a_signing_list_without_rs256) { + auto document{sourcemeta::core::parse_json(R"JSON({ + "issuer": "https://example.com", + "jwks_uri": "https://example.com/jwks", + "response_types_supported": [ "code" ], + "subject_types_supported": [ "public" ], + "id_token_signing_alg_values_supported": [ "ES256" ] + })JSON")}; + auto oauth{sourcemeta::core::OAuthServerMetadata::from( + std::move(document), "https://example.com")}; + EXPECT_TRUE(oauth.has_value()); + const auto metadata{ + sourcemeta::core::OIDCProviderMetadata::from(std::move(oauth).value())}; + EXPECT_FALSE(metadata.has_value()); +} + +TEST(from_oauth_preserves_the_underlying_oauth_view) { + auto oauth{sourcemeta::core::OAuthServerMetadata::from( + sourcemeta::core::JSON{VALID_PROVIDER_DOCUMENT}, "https://example.com")}; + EXPECT_TRUE(oauth.has_value()); + const auto metadata{ + sourcemeta::core::OIDCProviderMetadata::from(std::move(oauth).value())}; + EXPECT_TRUE(metadata.has_value()); + EXPECT_EQ(metadata.value().oauth().token_endpoint().value(), + "https://example.com/token"); + EXPECT_TRUE(metadata.value().oauth().supports_response_type("code")); +} diff --git a/test/oidc/oidc_request_object_test.cc b/test/oidc/oidc_request_object_test.cc index 27ab5994d..93420e2d5 100644 --- a/test/oidc/oidc_request_object_test.cc +++ b/test/oidc/oidc_request_object_test.cc @@ -6,6 +6,7 @@ #include // std::array #include // std::string #include // std::string_view +#include // std::move static constexpr std::string_view OCT_JWK{R"JSON({ "kty": "oct", @@ -19,10 +20,11 @@ static auto oct_private_key() -> sourcemeta::core::JWKPrivate { } static auto oct_key_set() -> sourcemeta::core::JWKS { - return sourcemeta::core::JWKS::from( - sourcemeta::core::parse_json(std::string{R"({ "keys": [ )"} + - std::string{OCT_JWK} + R"( ] })")) - .value(); + auto keys{sourcemeta::core::JSON::make_array()}; + keys.push_back(sourcemeta::core::parse_json(OCT_JWK)); + auto document{sourcemeta::core::JSON::make_object()}; + document.assign("keys", std::move(keys)); + return sourcemeta::core::JWKS::from(std::move(document)).value(); } static constexpr std::array allowed_hs256{ diff --git a/test/oidc/oidc_userinfo_test.cc b/test/oidc/oidc_userinfo_test.cc index b9bb357f8..7ccbc64b8 100644 --- a/test/oidc/oidc_userinfo_test.cc +++ b/test/oidc/oidc_userinfo_test.cc @@ -6,6 +6,7 @@ #include // std::array #include // std::string #include // std::string_view +#include // std::move static constexpr std::string_view OCT_JWK{R"JSON({ "kty": "oct", @@ -13,10 +14,11 @@ static constexpr std::string_view OCT_JWK{R"JSON({ })JSON"}; static auto oct_key_set() -> sourcemeta::core::JWKS { - return sourcemeta::core::JWKS::from( - sourcemeta::core::parse_json(std::string{R"({ "keys": [ )"} + - std::string{OCT_JWK} + R"( ] })")) - .value(); + auto keys{sourcemeta::core::JSON::make_array()}; + keys.push_back(sourcemeta::core::parse_json(OCT_JWK)); + auto document{sourcemeta::core::JSON::make_object()}; + document.assign("keys", std::move(keys)); + return sourcemeta::core::JWKS::from(std::move(document)).value(); } static auto sign_userinfo(const std::string_view payload) -> std::string {