From 82e6236673fb6360722adcb41bf7bf60ff9ac766 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 19 Jul 2026 16:56:34 -0300 Subject: [PATCH 1/5] Add an OAuth module foundation with error codes and PKCE Signed-off-by: Juan Cruz Viotti --- .github/workflows/website-build.yml | 1 + .github/workflows/website-deploy.yml | 1 + CMakeLists.txt | 9 + config.cmake.in | 20 ++ src/core/oauth/CMakeLists.txt | 20 ++ .../oauth/include/sourcemeta/core/oauth.h | 25 ++ .../include/sourcemeta/core/oauth_bearer.h | 66 ++++ .../include/sourcemeta/core/oauth_error.h | 286 ++++++++++++++++ .../include/sourcemeta/core/oauth_pkce.h | 150 ++++++++ .../include/sourcemeta/core/oauth_profile.h | 34 ++ src/core/oauth/oauth_bearer.cc | 144 ++++++++ src/core/oauth/oauth_error.cc | 196 +++++++++++ src/core/oauth/oauth_pkce.cc | 125 +++++++ src/core/oauth/oauth_syntax.h | 39 +++ test/oauth/CMakeLists.txt | 7 + test/oauth/oauth_bearer_test.cc | 319 ++++++++++++++++++ test/oauth/oauth_error_test.cc | 303 +++++++++++++++++ test/oauth/oauth_pkce_test.cc | 211 ++++++++++++ 18 files changed, 1956 insertions(+) create mode 100644 src/core/oauth/CMakeLists.txt create mode 100644 src/core/oauth/include/sourcemeta/core/oauth.h create mode 100644 src/core/oauth/include/sourcemeta/core/oauth_bearer.h create mode 100644 src/core/oauth/include/sourcemeta/core/oauth_error.h create mode 100644 src/core/oauth/include/sourcemeta/core/oauth_pkce.h create mode 100644 src/core/oauth/include/sourcemeta/core/oauth_profile.h create mode 100644 src/core/oauth/oauth_bearer.cc create mode 100644 src/core/oauth/oauth_error.cc create mode 100644 src/core/oauth/oauth_pkce.cc create mode 100644 src/core/oauth/oauth_syntax.h create mode 100644 test/oauth/CMakeLists.txt create mode 100644 test/oauth/oauth_bearer_test.cc create mode 100644 test/oauth/oauth_error_test.cc create mode 100644 test/oauth/oauth_pkce_test.cc diff --git a/.github/workflows/website-build.yml b/.github/workflows/website-build.yml index 9701b0a94..020db6fd6 100644 --- a/.github/workflows/website-build.yml +++ b/.github/workflows/website-build.yml @@ -50,6 +50,7 @@ jobs: -DSOURCEMETA_CORE_MCP:BOOL=OFF -DSOURCEMETA_CORE_HTTP:BOOL=OFF -DSOURCEMETA_CORE_JOSE:BOOL=OFF + -DSOURCEMETA_CORE_OAUTH:BOOL=OFF -DSOURCEMETA_CORE_SEMVER:BOOL=OFF -DSOURCEMETA_CORE_GZIP:BOOL=OFF -DSOURCEMETA_CORE_HTML:BOOL=OFF diff --git a/.github/workflows/website-deploy.yml b/.github/workflows/website-deploy.yml index 4aa5acdf3..ae4cdcd24 100644 --- a/.github/workflows/website-deploy.yml +++ b/.github/workflows/website-deploy.yml @@ -60,6 +60,7 @@ jobs: -DSOURCEMETA_CORE_MCP:BOOL=OFF -DSOURCEMETA_CORE_HTTP:BOOL=OFF -DSOURCEMETA_CORE_JOSE:BOOL=OFF + -DSOURCEMETA_CORE_OAUTH:BOOL=OFF -DSOURCEMETA_CORE_SEMVER:BOOL=OFF -DSOURCEMETA_CORE_GZIP:BOOL=OFF -DSOURCEMETA_CORE_HTML:BOOL=OFF diff --git a/CMakeLists.txt b/CMakeLists.txt index 72e264109..1be12a1f3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,7 @@ option(SOURCEMETA_CORE_MCP "Build the Sourcemeta Core MCP library" ON) option(SOURCEMETA_CORE_HTTP "Build the Sourcemeta Core HTTP library" ON) option(SOURCEMETA_CORE_HTTP_USE_SYSTEM_CURL "Use system cURL for the Sourcemeta Core HTTP library" OFF) option(SOURCEMETA_CORE_JOSE "Build the Sourcemeta Core JOSE library" ON) +option(SOURCEMETA_CORE_OAUTH "Build the Sourcemeta Core OAuth library" ON) option(SOURCEMETA_CORE_SEMVER "Build the Sourcemeta Core SemVer library" ON) option(SOURCEMETA_CORE_GZIP "Build the Sourcemeta Core GZIP library" ON) option(SOURCEMETA_CORE_HTML "Build the Sourcemeta Core HTML library" ON) @@ -221,6 +222,10 @@ if(SOURCEMETA_CORE_JOSE) add_subdirectory(src/core/jose) endif() +if(SOURCEMETA_CORE_OAUTH) + add_subdirectory(src/core/oauth) +endif() + if(SOURCEMETA_CORE_SEMVER) add_subdirectory(src/core/semver) endif() @@ -389,6 +394,10 @@ if(SOURCEMETA_CORE_TESTS) add_subdirectory(test/jose) endif() + if(SOURCEMETA_CORE_OAUTH) + add_subdirectory(test/oauth) + endif() + if(SOURCEMETA_CORE_SEMVER) add_subdirectory(test/semver) endif() diff --git a/config.cmake.in b/config.cmake.in index d2661ede6..9897dc11d 100644 --- a/config.cmake.in +++ b/config.cmake.in @@ -31,6 +31,7 @@ if(NOT SOURCEMETA_CORE_COMPONENTS) list(APPEND SOURCEMETA_CORE_COMPONENTS mcp) list(APPEND SOURCEMETA_CORE_COMPONENTS http) list(APPEND SOURCEMETA_CORE_COMPONENTS jose) + list(APPEND SOURCEMETA_CORE_COMPONENTS oauth) list(APPEND SOURCEMETA_CORE_COMPONENTS semver) list(APPEND SOURCEMETA_CORE_COMPONENTS gzip) list(APPEND SOURCEMETA_CORE_COMPONENTS html) @@ -228,6 +229,25 @@ foreach(component ${SOURCEMETA_CORE_COMPONENTS}) include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_json.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_crypto.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_jose.cmake") + elseif(component STREQUAL "oauth") + if(@SOURCEMETA_CORE_CRYPTO_USE_SYSTEM_OPENSSL@) + find_dependency(OpenSSL 3.0) + endif() + if(@SOURCEMETA_CORE_HTTP_USE_SYSTEM_CURL@) + find_dependency(CURL) + endif() + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_preprocessor.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_numeric.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_io.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_unicode.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_text.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_time.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_json.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_uri.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_crypto.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_jose.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_http.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_oauth.cmake") elseif(component STREQUAL "semver") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_preprocessor.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_text.cmake") diff --git a/src/core/oauth/CMakeLists.txt b/src/core/oauth/CMakeLists.txt new file mode 100644 index 000000000..5553af053 --- /dev/null +++ b/src/core/oauth/CMakeLists.txt @@ -0,0 +1,20 @@ +sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME oauth + PRIVATE_HEADERS error.h profile.h pkce.h bearer.h + SOURCES oauth_error.cc oauth_pkce.cc oauth_bearer.cc oauth_syntax.h) + +target_link_libraries(sourcemeta_core_oauth + PUBLIC sourcemeta::core::json) +target_link_libraries(sourcemeta_core_oauth + PUBLIC sourcemeta::core::crypto) +target_link_libraries(sourcemeta_core_oauth + PUBLIC sourcemeta::core::jose) +target_link_libraries(sourcemeta_core_oauth + PRIVATE sourcemeta::core::http) +target_link_libraries(sourcemeta_core_oauth + PRIVATE sourcemeta::core::uri) +target_link_libraries(sourcemeta_core_oauth + PRIVATE sourcemeta::core::text) + +if(SOURCEMETA_CORE_INSTALL) + sourcemeta_library_install(NAMESPACE sourcemeta PROJECT core NAME oauth) +endif() diff --git a/src/core/oauth/include/sourcemeta/core/oauth.h b/src/core/oauth/include/sourcemeta/core/oauth.h new file mode 100644 index 000000000..1b09409fa --- /dev/null +++ b/src/core/oauth/include/sourcemeta/core/oauth.h @@ -0,0 +1,25 @@ +#ifndef SOURCEMETA_CORE_OAUTH_H_ +#define SOURCEMETA_CORE_OAUTH_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +// NOLINTBEGIN(misc-include-cleaner) +#include +#include +#include +#include +// NOLINTEND(misc-include-cleaner) + +/// @defgroup oauth OAuth +/// @brief A standards-driven implementation of the OAuth 2.0 and 2.1 message +/// family. +/// +/// This functionality is included as follows: +/// +/// ```cpp +/// #include +/// ``` + +#endif diff --git a/src/core/oauth/include/sourcemeta/core/oauth_bearer.h b/src/core/oauth/include/sourcemeta/core/oauth_bearer.h new file mode 100644 index 000000000..0996c6356 --- /dev/null +++ b/src/core/oauth/include/sourcemeta/core/oauth_bearer.h @@ -0,0 +1,66 @@ +#ifndef SOURCEMETA_CORE_OAUTH_BEARER_H_ +#define SOURCEMETA_CORE_OAUTH_BEARER_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include // std::optional +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// Append a `Bearer` credential (RFC 6750 Section 2.1) for an access token to +/// the sink, returning whether the token is a well-formed `b64token`. Nothing +/// is appended when it is not. The result carries the access token, which is +/// secret, so a caller that keeps it should hold it in wiping storage. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string header; +/// assert(sourcemeta::core::oauth_bearer_header("mF_9.B5f-4.1JqM", header)); +/// assert(header == "Bearer mF_9.B5f-4.1JqM"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_bearer_header(const std::string_view token, std::string &sink) + -> bool; + +/// @ingroup oauth +/// Find the value of one authentication parameter within the challenge for a +/// scheme in a `WWW-Authenticate` header value (RFC 7235 Section 4.1), +/// returning no value when the scheme or parameter is absent, or the header is +/// malformed before the parameter is reached. The scheme and parameter name are +/// matched case insensitively. A +/// quoted-string value is unescaped (RFC 9110 Section 5.6.4) into the storage +/// arena and the result borrows from it, otherwise the result borrows from the +/// input. The full grammar is parsed so that adjacent challenges and parameters +/// are told apart correctly. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// std::string storage; +/// const auto realm{sourcemeta::core::oauth_challenge_parameter( +/// R"(Bearer realm="example", error="invalid_token")", "Bearer", "realm", +/// storage)}; +/// assert(realm.has_value()); +/// assert(realm.value() == "example"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_challenge_parameter(const std::string_view header, + const std::string_view scheme, + const std::string_view name, + std::string &storage) + -> std::optional; + +} // namespace sourcemeta::core + +#endif diff --git a/src/core/oauth/include/sourcemeta/core/oauth_error.h b/src/core/oauth/include/sourcemeta/core/oauth_error.h new file mode 100644 index 000000000..518404568 --- /dev/null +++ b/src/core/oauth/include/sourcemeta/core/oauth_error.h @@ -0,0 +1,286 @@ +#ifndef SOURCEMETA_CORE_OAUTH_ERROR_H_ +#define SOURCEMETA_CORE_OAUTH_ERROR_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include // std::uint8_t, std::uint16_t +#include // std::exception +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The error codes an authorization endpoint returns in its redirect +/// (RFC 6749 Section 4.1.2.1). +enum class OAuthAuthorizationError : std::uint8_t { + /// The request is missing a parameter, includes an invalid value, or is + /// otherwise malformed. + InvalidRequest, + /// The client is not authorized to request a code using this method. + UnauthorizedClient, + /// The resource owner or authorization server denied the request. + AccessDenied, + /// The authorization server does not support this response type. + UnsupportedResponseType, + /// The requested scope is invalid, unknown, or malformed. + InvalidScope, + /// The authorization server encountered an unexpected condition. + ServerError, + /// The authorization server is temporarily unable to handle the request. + TemporarilyUnavailable +}; + +/// @ingroup oauth +/// The error codes a token endpoint returns (RFC 6749 Section 5.2), extended +/// with the codes the device grant, resource indicators, token exchange, DPoP, +/// and token revocation add to the same endpoint family. +enum class OAuthTokenError : std::uint8_t { + /// The request is missing a parameter, includes an unsupported value, or is + /// otherwise malformed (RFC 6749 Section 5.2). + InvalidRequest, + /// Client authentication failed (RFC 6749 Section 5.2). + InvalidClient, + /// The grant or refresh token is invalid, expired, revoked, or mismatched + /// (RFC 6749 Section 5.2). + InvalidGrant, + /// The authenticated client is not authorized to use this grant type + /// (RFC 6749 Section 5.2). + UnauthorizedClient, + /// The grant type is not supported by the authorization server (RFC 6749 + /// Section 5.2). + UnsupportedGrantType, + /// The requested scope is invalid, unknown, or exceeds the grant (RFC 6749 + /// Section 5.2). + InvalidScope, + /// The device authorization is still pending user approval (RFC 8628 + /// Section 3.5). + AuthorizationPending, + /// The client is polling too frequently and must slow down (RFC 8628 + /// Section 3.5). + SlowDown, + /// The end user denied the device authorization request (RFC 8628 + /// Section 3.5). + AccessDenied, + /// The device code expired before the user approved it (RFC 8628 + /// Section 3.5). + ExpiredToken, + /// The requested resource or audience is invalid or unknown (RFC 8707 + /// Section 2, RFC 8693 Section 2.2.2). + InvalidTarget, + /// The DPoP proof is missing or invalid (RFC 9449 Section 5). + InvalidDPoPProof, + /// The authorization server requires the client to use a DPoP nonce + /// (RFC 9449 Section 8). + UseDPoPNonce, + /// The token type is not supported by the revocation endpoint (RFC 7009 + /// Section 2.2.1). + UnsupportedTokenType +}; + +/// @ingroup oauth +/// The error codes a protected resource returns in its `WWW-Authenticate` +/// challenge (RFC 6750 Section 3.1), shared by the `Bearer` and `DPoP` +/// schemes. +enum class OAuthBearerError : std::uint8_t { + /// The request is malformed (RFC 6750 Section 3.1). + InvalidRequest, + /// The access token is expired, revoked, malformed, or otherwise invalid + /// (RFC 6750 Section 3.1). + InvalidToken, + /// The token does not carry the scope the request requires (RFC 6750 + /// Section 3.1). + InsufficientScope +}; + +/// @ingroup oauth +/// The error codes a dynamic client registration endpoint returns (RFC 7591 +/// Section 3.2.2). +enum class OAuthRegistrationError : std::uint8_t { + /// A redirect URI in the request is invalid. + InvalidRedirectURI, + /// A field in the client metadata is invalid. + InvalidClientMetadata, + /// The software statement is invalid. + InvalidSoftwareStatement, + /// The software statement is not approved by the authorization server. + UnapprovedSoftwareStatement +}; + +/// @ingroup oauth +/// The wire code for an authorization endpoint error (RFC 6749 +/// Section 4.1.2.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthAuthorizationError::AccessDenied) == +/// "access_denied"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthAuthorizationError error) noexcept + -> std::string_view; + +/// @ingroup oauth +/// The wire code for a token endpoint error (RFC 6749 Section 5.2 and its +/// extensions). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthTokenError::InvalidGrant) == +/// "invalid_grant"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthTokenError error) noexcept -> std::string_view; + +/// @ingroup oauth +/// The wire code for a protected resource challenge error (RFC 6750 +/// Section 3.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthBearerError::InvalidToken) == +/// "invalid_token"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthBearerError error) noexcept + -> std::string_view; + +/// @ingroup oauth +/// The wire code for a dynamic client registration error (RFC 7591 +/// Section 3.2.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_error_code( +/// sourcemeta::core::OAuthRegistrationError::InvalidRedirectURI) == +/// "invalid_redirect_uri"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_error_code(const OAuthRegistrationError error) noexcept + -> std::string_view; + +/// @ingroup oauth +/// Map an authorization endpoint error code to its value, returning no value +/// for an unrecognized code (RFC 6749 Section 4.1.2.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_authorization_error("invalid_scope") +/// .has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_authorization_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// Map a token endpoint error code to its value, returning no value for an +/// unrecognized code (RFC 6749 Section 5.2 and its extensions). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_token_error("slow_down").has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_token_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// Map a protected resource challenge error code to its value, returning no +/// value for an unrecognized code (RFC 6750 Section 3.1). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_bearer_error("invalid_token").has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_bearer_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// Map a dynamic client registration error code to its value, returning no +/// value for an unrecognized code (RFC 7591 Section 3.2.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_registration_error("invalid_redirect_uri") +/// .has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_registration_error(const std::string_view code) noexcept + -> std::optional; + +/// @ingroup oauth +/// The HTTP status code for a token endpoint error response. It is 400 in +/// general, but 401 for a client authentication failure when the client +/// authenticated through the `Authorization` header, since that response must +/// then carry a `WWW-Authenticate` challenge (RFC 6749 Section 5.2). For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_token_error_status( +/// sourcemeta::core::OAuthTokenError::InvalidClient, true) == 401); +/// assert(sourcemeta::core::oauth_token_error_status( +/// sourcemeta::core::OAuthTokenError::InvalidGrant, true) == 400); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_token_error_status(const OAuthTokenError error, + const bool authenticated_via_header) noexcept + -> std::uint16_t; + +#if defined(_MSC_VER) +#pragma warning(disable : 4251 4275) +#endif + +/// @ingroup oauth +/// An error that occurs when parsing an invalid authorization server or +/// protected resource metadata document. +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthMetadataParseError + : public std::exception { +public: + [[nodiscard]] auto what() const noexcept -> const char * override { + return "The input is not a valid OAuth metadata document"; + } +}; + +/// @ingroup oauth +/// An error that occurs when parsing an invalid dynamic client registration +/// request or response. +class SOURCEMETA_CORE_OAUTH_EXPORT OAuthRegistrationParseError + : public std::exception { +public: + [[nodiscard]] auto what() const noexcept -> const char * override { + return "The input is not a valid OAuth client registration document"; + } +}; + +#if defined(_MSC_VER) +#pragma warning(default : 4251 4275) +#endif + +} // namespace sourcemeta::core + +#endif diff --git a/src/core/oauth/include/sourcemeta/core/oauth_pkce.h b/src/core/oauth/include/sourcemeta/core/oauth_pkce.h new file mode 100644 index 000000000..8089a3804 --- /dev/null +++ b/src/core/oauth/include/sourcemeta/core/oauth_pkce.h @@ -0,0 +1,150 @@ +#ifndef SOURCEMETA_CORE_OAUTH_PKCE_H_ +#define SOURCEMETA_CORE_OAUTH_PKCE_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::array +#include // std::uint8_t +#include // std::optional +#include // std::string_view + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The PKCE code challenge transformation method (RFC 7636 Section 4.3). +enum class OAuthPKCEMethod : std::uint8_t { + /// The SHA-256 transformation, the only method the strict profile permits + /// (RFC 7636 Section 4.2). + S256, + /// The identity transformation, permitted only under the compatible profile + /// (RFC 7636 Section 4.4). + Plain +}; + +/// @ingroup oauth +/// The result of verifying a PKCE code verifier against a stored code +/// challenge. Only `Match` and `NotUsed` let the exchange proceed. The +/// remaining outcomes each name a distinct pairing failure the token endpoint +/// rejects (RFC 7636 Section 4.6, RFC 9700 Section 2.1.1, OAuth 2.1 +/// Section 4.1.3). +enum class OAuthPKCEOutcome : std::uint8_t { + /// The verifier corresponds to the challenge. + Match, + /// Neither a challenge nor a verifier is present, so PKCE was not used. The + /// caller decides separately whether its profile required it. + NotUsed, + /// A challenge is stored but no verifier was presented (OAuth 2.1 + /// Section 4.1.3). + MissingVerifier, + /// A verifier was presented but no challenge is stored, which signals a + /// possible authorization code injection (RFC 9700 Section 2.1.1). + MissingChallenge, + /// Both are present, well formed, and permitted, but the verifier does not + /// correspond to the challenge (RFC 7636 Section 4.6). + Mismatch, + /// The stored method is `plain` under the strict profile (RFC 9700 + /// Section 2.1.1). + MethodNotAllowed, + /// The presented verifier is not a valid code verifier (RFC 7636 + /// Section 4.1). + MalformedVerifier, + /// The stored challenge violates the code challenge syntax, either the wrong + /// length for its method or a character outside the allowed set (RFC 7636 + /// Sections 4.2 and 4.3). A syntactically valid challenge that simply does + /// not correspond to the verifier is a `Mismatch`, not this. + MalformedChallenge +}; + +/// @ingroup oauth +/// The wire value for a PKCE challenge method (RFC 7636 Section 4.3). For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::oauth_pkce_method_code( +/// sourcemeta::core::OAuthPKCEMethod::S256) == "S256"); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_method_code(const OAuthPKCEMethod method) noexcept + -> std::string_view; + +/// @ingroup oauth +/// Map a PKCE challenge method wire value to its type, returning no value for +/// an unrecognized method (RFC 7636 Section 4.3). For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// assert(sourcemeta::core::to_oauth_pkce_method("S256").has_value()); +/// assert(!sourcemeta::core::to_oauth_pkce_method("plaintext").has_value()); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto to_oauth_pkce_method(const std::string_view value) noexcept + -> std::optional; + +/// @ingroup oauth +/// Mint a new PKCE code verifier, the base64url encoding of 32 +/// cryptographically random octets (RFC 7636 Section 4.1). The result is secret +/// material, so wipe it once the exchange completes. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto verifier{sourcemeta::core::oauth_pkce_verifier()}; +/// assert(verifier.size() == 43); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_verifier() -> std::array; + +/// @ingroup oauth +/// Derive the S256 code challenge for a code verifier, the base64url encoding +/// of its SHA-256 digest (RFC 7636 Section 4.2). For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto challenge{sourcemeta::core::oauth_pkce_challenge( +/// "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")}; +/// assert((std::string_view{challenge.data(), challenge.size()} == +/// "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_challenge(const std::string_view verifier) + -> std::array; + +/// @ingroup oauth +/// Verify a presented code verifier against a stored code challenge and its +/// method, under the given profile, returning the pairing outcome. An empty +/// verifier or challenge means the value is absent. The final verifier against +/// challenge comparison is constant time. For example: +/// +/// ```cpp +/// #include +/// #include +/// +/// const auto outcome{sourcemeta::core::oauth_pkce_verify( +/// "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", +/// "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", +/// sourcemeta::core::OAuthPKCEMethod::S256, +/// sourcemeta::core::OAuthProfile::Strict)}; +/// assert(outcome == sourcemeta::core::OAuthPKCEOutcome::Match); +/// ``` +SOURCEMETA_CORE_OAUTH_EXPORT +auto oauth_pkce_verify(const std::string_view verifier, + const std::string_view challenge, + const OAuthPKCEMethod method, const OAuthProfile profile) + -> OAuthPKCEOutcome; + +} // namespace sourcemeta::core + +#endif diff --git a/src/core/oauth/include/sourcemeta/core/oauth_profile.h b/src/core/oauth/include/sourcemeta/core/oauth_profile.h new file mode 100644 index 000000000..87c28a17c --- /dev/null +++ b/src/core/oauth/include/sourcemeta/core/oauth_profile.h @@ -0,0 +1,34 @@ +#ifndef SOURCEMETA_CORE_OAUTH_PROFILE_H_ +#define SOURCEMETA_CORE_OAUTH_PROFILE_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include // std::uint8_t + +namespace sourcemeta::core { + +/// @ingroup oauth +/// The behavioural profile a parser or validator runs under. `Strict` applies +/// the OAuth 2.1 and RFC 9700 hardening, and `Compatible` relaxes it for +/// RFC 6749 interoperability where a specification still permits the older +/// behaviour. For example: +/// +/// ```cpp +/// #include +/// +/// const auto profile{sourcemeta::core::OAuthProfile::Strict}; +/// ``` +enum class OAuthProfile : std::uint8_t { + /// The default. Rejects the `plain` PKCE method, requires exact redirect URI + /// matching, and refuses bearer tokens outside the `Authorization` header. + Strict, + /// Accepts the RFC 6749 behaviours the strict profile refuses, for + /// interoperability with deployments that predate the hardening. + Compatible +}; + +} // namespace sourcemeta::core + +#endif diff --git a/src/core/oauth/oauth_bearer.cc b/src/core/oauth/oauth_bearer.cc new file mode 100644 index 000000000..020e54465 --- /dev/null +++ b/src/core/oauth/oauth_bearer.cc @@ -0,0 +1,144 @@ +#include + +#include +#include + +#include // std::size_t +#include // std::optional, std::nullopt +#include // std::string +#include // std::string_view + +namespace sourcemeta::core { + +namespace { + +auto oauth_skip_ows(const std::string_view value, std::size_t position) noexcept + -> std::size_t { + while (position < value.size() && http_is_ows(value[position])) { + position += 1; + } + + return position; +} + +} // namespace + +auto oauth_bearer_header(const std::string_view token, std::string &sink) + -> bool { + if (!http_is_b64token(token)) { + return false; + } + + static constexpr std::string_view prefix{"Bearer "}; + sink.reserve(sink.size() + prefix.size() + token.size()); + sink.append(prefix); + sink.append(token); + return true; +} + +auto oauth_challenge_parameter(const std::string_view header, + const std::string_view scheme, + const std::string_view name, + std::string &storage) + -> std::optional { + // Reserving the whole input up front means the unescaping below never + // reallocates storage, so a value it returns stays valid while the scan + // continues + storage.reserve(storage.size() + header.size()); + std::size_t position{0}; + // The scheme of the challenge currently being read. RFC 7235 shares the comma + // between the challenge list and each challenge's parameter list, so a token + // is a new scheme unless it is immediately followed by "=", in which case it + // is a parameter of the current challenge + std::string_view current_scheme{}; + while (position < header.size()) { + // RFC 9110 Section 5.6.1 list rule: skip optional whitespace and the commas + // that separate elements, tolerating empty elements + while (position < header.size() && + (http_is_ows(header[position]) || header[position] == ',')) { + position += 1; + } + + if (position >= header.size()) { + break; + } + + const std::size_t token_start{position}; + while (position < header.size() && http_is_tchar(header[position])) { + position += 1; + } + + if (position == token_start) { + return std::nullopt; + } + + const auto token{header.substr(token_start, position - token_start)}; + const auto after_token{position}; + const auto equals{oauth_skip_ows(header, position)}; + if (equals < header.size() && header[equals] == '=') { + if (current_scheme.empty()) { + return std::nullopt; + } + + position = oauth_skip_ows(header, equals + 1); + const bool match{equals_ignore_case(current_scheme, scheme) && + equals_ignore_case(token, name)}; + std::string_view value{}; + if (position < header.size() && header[position] == '"') { + const auto next{ + http_scan_quoted_string(header, position, storage, value)}; + if (!next.has_value()) { + return std::nullopt; + } + + position = next.value(); + } else { + const std::size_t value_start{position}; + while (position < header.size() && http_is_tchar(header[position])) { + position += 1; + } + + if (position == value_start) { + return std::nullopt; + } + + value = header.substr(value_start, position - value_start); + } + + if (match) { + return value; + } + + continue; + } + + // The token is a new scheme. It may be followed by a token68 credential, + // which is skipped whole so the scan reaches the next challenge + current_scheme = token; + const auto separator{oauth_skip_ows(header, after_token)}; + if (separator > after_token && separator < header.size()) { + std::size_t blob{separator}; + while (blob < header.size() && http_is_b64token_char(header[blob])) { + blob += 1; + } + + while (blob < header.size() && header[blob] == '=') { + blob += 1; + } + + // A token68 must be a valid token68 (RFC 7235 Section 2.1) and must end + // the challenge, so a bare padding run is not one and a parameter cannot + // attach to the token68 form of a challenge + const auto terminator{oauth_skip_ows(header, blob)}; + if ((terminator >= header.size() || header[terminator] == ',') && + http_is_b64token(header.substr(separator, blob - separator))) { + position = blob; + current_scheme = {}; + } + } + } + + return std::nullopt; +} + +} // namespace sourcemeta::core diff --git a/src/core/oauth/oauth_error.cc b/src/core/oauth/oauth_error.cc new file mode 100644 index 000000000..a6a7b2834 --- /dev/null +++ b/src/core/oauth/oauth_error.cc @@ -0,0 +1,196 @@ +#include + +#include // std::uint16_t +#include // std::optional, std::nullopt +#include // std::string_view +#include // std::unreachable + +namespace sourcemeta::core { + +auto oauth_error_code(const OAuthAuthorizationError error) noexcept + -> std::string_view { + switch (error) { + case OAuthAuthorizationError::InvalidRequest: + return "invalid_request"; + case OAuthAuthorizationError::UnauthorizedClient: + return "unauthorized_client"; + case OAuthAuthorizationError::AccessDenied: + return "access_denied"; + case OAuthAuthorizationError::UnsupportedResponseType: + return "unsupported_response_type"; + case OAuthAuthorizationError::InvalidScope: + return "invalid_scope"; + case OAuthAuthorizationError::ServerError: + return "server_error"; + case OAuthAuthorizationError::TemporarilyUnavailable: + return "temporarily_unavailable"; + } + + std::unreachable(); +} + +auto oauth_error_code(const OAuthTokenError error) noexcept + -> std::string_view { + switch (error) { + case OAuthTokenError::InvalidRequest: + return "invalid_request"; + case OAuthTokenError::InvalidClient: + return "invalid_client"; + case OAuthTokenError::InvalidGrant: + return "invalid_grant"; + case OAuthTokenError::UnauthorizedClient: + return "unauthorized_client"; + case OAuthTokenError::UnsupportedGrantType: + return "unsupported_grant_type"; + case OAuthTokenError::InvalidScope: + return "invalid_scope"; + case OAuthTokenError::AuthorizationPending: + return "authorization_pending"; + case OAuthTokenError::SlowDown: + return "slow_down"; + case OAuthTokenError::AccessDenied: + return "access_denied"; + case OAuthTokenError::ExpiredToken: + return "expired_token"; + case OAuthTokenError::InvalidTarget: + return "invalid_target"; + case OAuthTokenError::InvalidDPoPProof: + return "invalid_dpop_proof"; + case OAuthTokenError::UseDPoPNonce: + return "use_dpop_nonce"; + case OAuthTokenError::UnsupportedTokenType: + return "unsupported_token_type"; + } + + std::unreachable(); +} + +auto oauth_error_code(const OAuthBearerError error) noexcept + -> std::string_view { + switch (error) { + case OAuthBearerError::InvalidRequest: + return "invalid_request"; + case OAuthBearerError::InvalidToken: + return "invalid_token"; + case OAuthBearerError::InsufficientScope: + return "insufficient_scope"; + } + + std::unreachable(); +} + +auto oauth_error_code(const OAuthRegistrationError error) noexcept + -> std::string_view { + switch (error) { + case OAuthRegistrationError::InvalidRedirectURI: + return "invalid_redirect_uri"; + case OAuthRegistrationError::InvalidClientMetadata: + return "invalid_client_metadata"; + case OAuthRegistrationError::InvalidSoftwareStatement: + return "invalid_software_statement"; + case OAuthRegistrationError::UnapprovedSoftwareStatement: + return "unapproved_software_statement"; + } + + std::unreachable(); +} + +auto to_oauth_authorization_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_request") { + return OAuthAuthorizationError::InvalidRequest; + } else if (code == "unauthorized_client") { + return OAuthAuthorizationError::UnauthorizedClient; + } else if (code == "access_denied") { + return OAuthAuthorizationError::AccessDenied; + } else if (code == "unsupported_response_type") { + return OAuthAuthorizationError::UnsupportedResponseType; + } else if (code == "invalid_scope") { + return OAuthAuthorizationError::InvalidScope; + } else if (code == "server_error") { + return OAuthAuthorizationError::ServerError; + } else if (code == "temporarily_unavailable") { + return OAuthAuthorizationError::TemporarilyUnavailable; + } else { + return std::nullopt; + } +} + +auto to_oauth_token_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_request") { + return OAuthTokenError::InvalidRequest; + } else if (code == "invalid_client") { + return OAuthTokenError::InvalidClient; + } else if (code == "invalid_grant") { + return OAuthTokenError::InvalidGrant; + } else if (code == "unauthorized_client") { + return OAuthTokenError::UnauthorizedClient; + } else if (code == "unsupported_grant_type") { + return OAuthTokenError::UnsupportedGrantType; + } else if (code == "invalid_scope") { + return OAuthTokenError::InvalidScope; + } else if (code == "authorization_pending") { + return OAuthTokenError::AuthorizationPending; + } else if (code == "slow_down") { + return OAuthTokenError::SlowDown; + } else if (code == "access_denied") { + return OAuthTokenError::AccessDenied; + } else if (code == "expired_token") { + return OAuthTokenError::ExpiredToken; + } else if (code == "invalid_target") { + return OAuthTokenError::InvalidTarget; + } else if (code == "invalid_dpop_proof") { + return OAuthTokenError::InvalidDPoPProof; + } else if (code == "use_dpop_nonce") { + return OAuthTokenError::UseDPoPNonce; + } else if (code == "unsupported_token_type") { + return OAuthTokenError::UnsupportedTokenType; + } else { + return std::nullopt; + } +} + +auto to_oauth_bearer_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_request") { + return OAuthBearerError::InvalidRequest; + } else if (code == "invalid_token") { + return OAuthBearerError::InvalidToken; + } else if (code == "insufficient_scope") { + return OAuthBearerError::InsufficientScope; + } else { + return std::nullopt; + } +} + +auto to_oauth_registration_error(const std::string_view code) noexcept + -> std::optional { + if (code == "invalid_redirect_uri") { + return OAuthRegistrationError::InvalidRedirectURI; + } else if (code == "invalid_client_metadata") { + return OAuthRegistrationError::InvalidClientMetadata; + } else if (code == "invalid_software_statement") { + return OAuthRegistrationError::InvalidSoftwareStatement; + } else if (code == "unapproved_software_statement") { + return OAuthRegistrationError::UnapprovedSoftwareStatement; + } else { + return std::nullopt; + } +} + +auto oauth_token_error_status(const OAuthTokenError error, + const bool authenticated_via_header) noexcept + -> std::uint16_t { + // RFC 6749 Section 5.2: "If the client attempted to authenticate via the + // 'Authorization' request header field, the authorization server MUST respond + // with an HTTP 401 (Unauthorized) status code and include the + // WWW-Authenticate response header field" + if (error == OAuthTokenError::InvalidClient && authenticated_via_header) { + return 401; + } + + return 400; +} + +} // namespace sourcemeta::core diff --git a/src/core/oauth/oauth_pkce.cc b/src/core/oauth/oauth_pkce.cc new file mode 100644 index 000000000..bb2747bd6 --- /dev/null +++ b/src/core/oauth/oauth_pkce.cc @@ -0,0 +1,125 @@ +#include + +#include + +#include "oauth_syntax.h" + +#include // std::ranges::copy +#include // std::array +#include // std::size_t +#include // std::uint8_t +#include // std::optional, std::nullopt +#include // std::span +#include // std::string_view +#include // std::unreachable + +namespace sourcemeta::core { + +auto oauth_pkce_method_code(const OAuthPKCEMethod method) noexcept + -> std::string_view { + switch (method) { + case OAuthPKCEMethod::S256: + return "S256"; + case OAuthPKCEMethod::Plain: + return "plain"; + } + + std::unreachable(); +} + +auto to_oauth_pkce_method(const std::string_view value) noexcept + -> std::optional { + if (value == "S256") { + return OAuthPKCEMethod::S256; + } else if (value == "plain") { + return OAuthPKCEMethod::Plain; + } else { + return std::nullopt; + } +} + +auto oauth_pkce_verifier() -> std::array { + // The verifier is the base64url of 32 random octets, so both the entropy and + // the encoded form are secret and are wiped before returning. The caller owns + // the returned value and is responsible for wiping it in turn + std::array entropy{}; + random_bytes(std::span{entropy}); + SecureString encoded; + base64url_encode(std::string_view{reinterpret_cast( // NOLINT + entropy.data()), + entropy.size()}, + encoded); + secure_zero(entropy.data(), entropy.size()); + + std::array result{}; + std::ranges::copy(std::string_view{encoded}, result.begin()); + return result; +} + +auto oauth_pkce_challenge(const std::string_view verifier) + -> std::array { + const auto digest{sha256_digest(verifier)}; + const auto encoded{base64url_encode( + std::string_view{reinterpret_cast(digest.data()), // NOLINT + digest.size()})}; + + std::array result{}; + std::ranges::copy(encoded, result.begin()); + return result; +} + +auto oauth_pkce_verify(const std::string_view verifier, + const std::string_view challenge, + const OAuthPKCEMethod method, const OAuthProfile profile) + -> OAuthPKCEOutcome { + if (challenge.empty()) { + // RFC 9700 Section 2.1.1: a presented verifier with no bound challenge is + // rejected, since it signals a possible authorization code injection + return verifier.empty() ? OAuthPKCEOutcome::NotUsed + : OAuthPKCEOutcome::MissingChallenge; + } + + // OAuth 2.1 Section 4.1.3: iff the authorization request carried a challenge, + // the token request must carry a verifier + if (verifier.empty()) { + return OAuthPKCEOutcome::MissingVerifier; + } + + if (!oauth_is_pkce_verifier(verifier)) { + return OAuthPKCEOutcome::MalformedVerifier; + } + + switch (method) { + case OAuthPKCEMethod::Plain: + // RFC 9700 Section 2.1.1 hardening: the plain method is rejected under + // the strict profile + if (profile == OAuthProfile::Strict) { + return OAuthPKCEOutcome::MethodNotAllowed; + } + + if (!oauth_is_pkce_challenge(challenge)) { + return OAuthPKCEOutcome::MalformedChallenge; + } + + // RFC 7636 Section 4.6: the plain method compares the two values directly + return secure_equals(verifier, challenge) ? OAuthPKCEOutcome::Match + : OAuthPKCEOutcome::Mismatch; + case OAuthPKCEMethod::S256: { + // RFC 7636 Section 4.2: an S256 challenge is the base64url of a SHA-256 + // digest, so it is always exactly 43 characters + if (challenge.size() != 43 || !oauth_is_pkce_challenge(challenge)) { + return OAuthPKCEOutcome::MalformedChallenge; + } + + const auto computed{oauth_pkce_challenge(verifier)}; + return secure_equals(std::string_view{computed.data(), computed.size()}, + challenge) + ? OAuthPKCEOutcome::Match + : OAuthPKCEOutcome::Mismatch; + } + } + + std::unreachable(); +} + +} // namespace sourcemeta::core diff --git a/src/core/oauth/oauth_syntax.h b/src/core/oauth/oauth_syntax.h new file mode 100644 index 000000000..057f1698b --- /dev/null +++ b/src/core/oauth/oauth_syntax.h @@ -0,0 +1,39 @@ +#ifndef SOURCEMETA_CORE_OAUTH_SYNTAX_H_ +#define SOURCEMETA_CORE_OAUTH_SYNTAX_H_ + +#include + +#include // std::ranges::all_of +#include // std::string_view + +namespace sourcemeta::core { + +// 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 { + return is_alphanum(character) || character == '-' || character == '.' || + character == '_' || character == '~'; +} + +inline auto oauth_is_unreserved_string(const std::string_view value) noexcept + -> bool { + return std::ranges::all_of(value, oauth_is_unreserved); +} + +// RFC 7636 Section 4.1: "code-verifier = 43*128unreserved" +inline auto oauth_is_pkce_verifier(const std::string_view value) noexcept + -> bool { + return value.size() >= 43 && value.size() <= 128 && + oauth_is_unreserved_string(value); +} + +// RFC 7636 Section 4.3: "code-challenge = 43*128unreserved" +inline auto oauth_is_pkce_challenge(const std::string_view value) noexcept + -> bool { + return value.size() >= 43 && value.size() <= 128 && + oauth_is_unreserved_string(value); +} + +} // namespace sourcemeta::core + +#endif diff --git a/test/oauth/CMakeLists.txt b/test/oauth/CMakeLists.txt new file mode 100644 index 000000000..4dd358b18 --- /dev/null +++ b/test/oauth/CMakeLists.txt @@ -0,0 +1,7 @@ +sourcemeta_test(NAMESPACE sourcemeta PROJECT core NAME oauth + SOURCES oauth_error_test.cc oauth_pkce_test.cc oauth_bearer_test.cc) + +target_link_libraries(sourcemeta_core_oauth_unit + PRIVATE sourcemeta::core::oauth) +target_link_libraries(sourcemeta_core_oauth_unit + PRIVATE sourcemeta::core::http) diff --git a/test/oauth/oauth_bearer_test.cc b/test/oauth/oauth_bearer_test.cc new file mode 100644 index 000000000..7f881c779 --- /dev/null +++ b/test/oauth/oauth_bearer_test.cc @@ -0,0 +1,319 @@ +#include +#include +#include + +#include // std::string + +TEST(build_a_bearer_header) { + std::string header; + EXPECT_TRUE(sourcemeta::core::oauth_bearer_header("mF_9.B5f-4.1JqM", header)); + EXPECT_EQ(header, "Bearer mF_9.B5f-4.1JqM"); +} + +TEST(build_appends_to_the_sink_without_clearing) { + std::string header{"prefix:"}; + EXPECT_TRUE(sourcemeta::core::oauth_bearer_header("abc123", header)); + EXPECT_EQ(header, "prefix:Bearer abc123"); +} + +TEST(build_accepts_base64_padding) { + std::string header; + EXPECT_TRUE(sourcemeta::core::oauth_bearer_header("YWJj==", header)); + EXPECT_EQ(header, "Bearer YWJj=="); +} + +TEST(build_rejects_a_token_with_a_space) { + std::string header; + EXPECT_FALSE(sourcemeta::core::oauth_bearer_header("has space", header)); + EXPECT_TRUE(header.empty()); +} + +TEST(build_rejects_an_empty_token) { + std::string header; + EXPECT_FALSE(sourcemeta::core::oauth_bearer_header("", header)); + EXPECT_TRUE(header.empty()); +} + +TEST(build_then_http_parse_round_trips) { + // Token parsing is provided by http_parse_bearer, so the built header must + // round-trip through it + std::string header; + EXPECT_TRUE(sourcemeta::core::oauth_bearer_header("abc123", header)); + EXPECT_EQ(sourcemeta::core::http_parse_bearer(header), "abc123"); +} + +TEST(challenge_parameter_finds_a_quoted_value) { + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="example")", "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "example"); +} + +TEST(challenge_parameter_finds_a_bare_token_value) { + std::string storage; + const auto error{sourcemeta::core::oauth_challenge_parameter( + "Bearer error=invalid_token", "Bearer", "error", storage)}; + EXPECT_TRUE(error.has_value()); + EXPECT_EQ(error.value(), "invalid_token"); +} + +TEST(challenge_parameter_finds_a_later_parameter_of_the_same_challenge) { + std::string storage; + const auto error{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="example", error="invalid_token")", "Bearer", "error", + storage)}; + EXPECT_TRUE(error.has_value()); + EXPECT_EQ(error.value(), "invalid_token"); +} + +TEST(challenge_parameter_matches_the_scheme_and_name_case_insensitively) { + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(bearer REALM="example")", "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "example"); +} + +TEST(challenge_parameter_tolerates_bad_whitespace_around_equals) { + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm = "example")", "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "example"); +} + +TEST(challenge_parameter_selects_the_named_scheme) { + std::string storage; + const auto algs{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="example", DPoP algs="ES256 PS256")", "DPoP", "algs", + storage)}; + EXPECT_TRUE(algs.has_value()); + EXPECT_EQ(algs.value(), "ES256 PS256"); +} + +TEST(challenge_parameter_does_not_cross_schemes) { + std::string storage; + // realm belongs to Bearer, not to DPoP, so a lookup on DPoP does not find it + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="example", DPoP algs="ES256")", "DPoP", "realm", + storage)}; + EXPECT_FALSE(realm.has_value()); +} + +TEST(challenge_parameter_handles_an_empty_first_challenge) { + std::string storage; + // RFC 9449 Figure 17: a schemes-only Bearer challenge alongside a DPoP one + const auto algs{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer, DPoP algs="ES256 PS256")", "DPoP", "algs", storage)}; + EXPECT_TRUE(algs.has_value()); + EXPECT_EQ(algs.value(), "ES256 PS256"); +} + +TEST(challenge_parameter_keeps_a_comma_inside_a_quoted_value) { + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="a,b")", "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "a,b"); +} + +TEST(challenge_parameter_unescapes_a_quoted_pair) { + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="say \"hi\"")", "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), R"(say "hi")"); +} + +TEST(challenge_parameter_tolerates_empty_list_elements) { + std::string storage; + const auto point{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="x",, DPoP algs="ES256")", "DPoP", "algs", storage)}; + EXPECT_TRUE(point.has_value()); + EXPECT_EQ(point.value(), "ES256"); +} + +TEST(challenge_parameter_skips_a_token68_challenge) { + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Negotiate YWJjZGVm==, Bearer realm="example")", "Bearer", "realm", + storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "example"); +} + +TEST(challenge_parameter_finds_a_resource_metadata_url) { + std::string storage; + const auto url{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer resource_metadata="https://rs.example/.well-known/x")", + "Bearer", "resource_metadata", storage)}; + EXPECT_TRUE(url.has_value()); + EXPECT_EQ(url.value(), "https://rs.example/.well-known/x"); +} + +TEST(challenge_parameter_returns_nothing_for_an_absent_parameter) { + std::string storage; + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="example")", "Bearer", "error", storage) + .has_value()); +} + +TEST(challenge_parameter_returns_nothing_for_an_absent_scheme) { + std::string storage; + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="example")", "DPoP", "algs", storage) + .has_value()); +} + +TEST(challenge_parameter_rejects_an_unterminated_quoted_string) { + std::string storage; + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="example)", "Bearer", "realm", storage) + .has_value()); +} + +TEST(challenge_parameter_parses_the_rfc7235_example) { + // RFC 7235 Section 4.1 example, adapted with the standard schemes + static constexpr std::string_view header{ + R"(Newauth realm="apps", type=1, title="Login", Basic realm="simple")"}; + std::string storage; + EXPECT_EQ(sourcemeta::core::oauth_challenge_parameter(header, "Newauth", + "realm", storage) + .value(), + "apps"); + std::string type_storage; + EXPECT_EQ(sourcemeta::core::oauth_challenge_parameter(header, "Newauth", + "type", type_storage) + .value(), + "1"); + std::string title_storage; + EXPECT_EQ(sourcemeta::core::oauth_challenge_parameter(header, "Newauth", + "title", title_storage) + .value(), + "Login"); + std::string basic_storage; + EXPECT_EQ(sourcemeta::core::oauth_challenge_parameter(header, "Basic", + "realm", basic_storage) + .value(), + "simple"); +} + +TEST(challenge_parameter_survives_earlier_escaped_values_in_the_arena) { + // Two escaped parameters are captured into the arena before the match, so the + // returned view must remain valid after those appends + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer a="\A", b="\B", realm="\Z")", "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "Z"); +} + +TEST(challenge_parameter_finds_a_later_challenge_of_the_same_scheme) { + std::string storage; + const auto error{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="a", Bearer error="b")", "Bearer", "error", storage)}; + EXPECT_TRUE(error.has_value()); + EXPECT_EQ(error.value(), "b"); +} + +TEST(challenge_parameter_keeps_equals_and_colon_in_a_quoted_value) { + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="a=b:c")", "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "a=b:c"); +} + +TEST(challenge_parameter_keeps_obs_text_in_a_quoted_value) { + std::string header{R"(Bearer realm=")"}; + header.push_back('\x80'); + header.append(R"(")"); + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + header, "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value().size(), 1); + EXPECT_EQ(static_cast(realm.value().front()), 0x80); +} + +TEST(challenge_parameter_skips_an_unpadded_token68_challenge) { + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Negotiate abcdef, Bearer realm="x")", "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "x"); +} + +TEST(challenge_parameter_skips_a_token68_with_plus_and_slash) { + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Negotiate a/b+c==, Bearer realm="x")", "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "x"); +} + +TEST(challenge_parameter_rejects_a_padding_only_blob) { + // "==" is not a valid token68 (it lacks the required alphabet character), so + // the header is malformed + std::string storage; + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Foo ==, Bearer realm="x")", "Bearer", "realm", storage) + .has_value()); +} + +TEST(challenge_parameter_rejects_a_parameter_after_a_token68) { + // A token68 form of a challenge cannot also carry parameters (RFC 7235 + // Section 2.1), so the trailing parameter makes the header malformed + std::string storage; + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer foo, resource_metadata="https://x")", "Bearer", + "resource_metadata", storage) + .has_value()); +} + +TEST(challenge_parameter_rejects_a_parameter_before_any_scheme) { + std::string storage; + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(realm="x")", "Bearer", "realm", storage) + .has_value()); +} + +TEST(challenge_parameter_rejects_an_empty_bare_value) { + std::string storage; + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + "Bearer realm=", "Bearer", "realm", storage) + .has_value()); +} + +TEST(challenge_parameter_returns_nothing_for_an_empty_header) { + std::string storage; + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter("", "Bearer", + "realm", storage) + .has_value()); +} + +TEST(challenge_parameter_returns_nothing_for_a_whitespace_header) { + std::string storage; + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter(" ", "Bearer", + "realm", storage) + .has_value()); +} + +TEST(challenge_parameter_tolerates_leading_and_trailing_commas) { + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(,,Bearer realm="x",,)", "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "x"); +} + +TEST(challenge_parameter_ignores_malformation_after_the_match) { + // The lookup returns as soon as the parameter is found, so trailing garbage + // after the matched value does not change the result + std::string storage; + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="x" garbage)", "Bearer", "realm", storage)}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "x"); +} diff --git a/test/oauth/oauth_error_test.cc b/test/oauth/oauth_error_test.cc new file mode 100644 index 000000000..d6272e627 --- /dev/null +++ b/test/oauth/oauth_error_test.cc @@ -0,0 +1,303 @@ +#include +#include + +TEST(serialize_authorization_error_codes) { + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthAuthorizationError::InvalidRequest), + "invalid_request"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthAuthorizationError::UnauthorizedClient), + "unauthorized_client"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthAuthorizationError::AccessDenied), + "access_denied"); + EXPECT_EQ( + sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthAuthorizationError::UnsupportedResponseType), + "unsupported_response_type"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthAuthorizationError::InvalidScope), + "invalid_scope"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthAuthorizationError::ServerError), + "server_error"); + EXPECT_EQ( + sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthAuthorizationError::TemporarilyUnavailable), + "temporarily_unavailable"); +} + +TEST(parse_authorization_error_codes) { + const auto invalid_request{ + sourcemeta::core::to_oauth_authorization_error("invalid_request")}; + EXPECT_TRUE(invalid_request.has_value()); + EXPECT_TRUE(invalid_request.value() == + sourcemeta::core::OAuthAuthorizationError::InvalidRequest); + const auto unauthorized_client{ + sourcemeta::core::to_oauth_authorization_error("unauthorized_client")}; + EXPECT_TRUE(unauthorized_client.has_value()); + EXPECT_TRUE(unauthorized_client.value() == + sourcemeta::core::OAuthAuthorizationError::UnauthorizedClient); + const auto access_denied{ + sourcemeta::core::to_oauth_authorization_error("access_denied")}; + EXPECT_TRUE(access_denied.has_value()); + EXPECT_TRUE(access_denied.value() == + sourcemeta::core::OAuthAuthorizationError::AccessDenied); + const auto unsupported_response_type{ + sourcemeta::core::to_oauth_authorization_error( + "unsupported_response_type")}; + EXPECT_TRUE(unsupported_response_type.has_value()); + EXPECT_TRUE( + unsupported_response_type.value() == + sourcemeta::core::OAuthAuthorizationError::UnsupportedResponseType); + const auto invalid_scope{ + sourcemeta::core::to_oauth_authorization_error("invalid_scope")}; + EXPECT_TRUE(invalid_scope.has_value()); + EXPECT_TRUE(invalid_scope.value() == + sourcemeta::core::OAuthAuthorizationError::InvalidScope); + const auto server_error{ + sourcemeta::core::to_oauth_authorization_error("server_error")}; + EXPECT_TRUE(server_error.has_value()); + EXPECT_TRUE(server_error.value() == + sourcemeta::core::OAuthAuthorizationError::ServerError); + const auto temporarily_unavailable{ + sourcemeta::core::to_oauth_authorization_error( + "temporarily_unavailable")}; + EXPECT_TRUE(temporarily_unavailable.has_value()); + EXPECT_TRUE( + temporarily_unavailable.value() == + sourcemeta::core::OAuthAuthorizationError::TemporarilyUnavailable); +} + +TEST(parse_authorization_error_rejects_an_unknown_code) { + EXPECT_FALSE(sourcemeta::core::to_oauth_authorization_error("invalid_client") + .has_value()); + EXPECT_FALSE(sourcemeta::core::to_oauth_authorization_error("").has_value()); +} + +TEST(serialize_token_error_codes) { + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::InvalidRequest), + "invalid_request"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::InvalidClient), + "invalid_client"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::InvalidGrant), + "invalid_grant"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::UnauthorizedClient), + "unauthorized_client"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::UnsupportedGrantType), + "unsupported_grant_type"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::InvalidScope), + "invalid_scope"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::AuthorizationPending), + "authorization_pending"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::SlowDown), + "slow_down"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::AccessDenied), + "access_denied"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::ExpiredToken), + "expired_token"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::InvalidTarget), + "invalid_target"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::InvalidDPoPProof), + "invalid_dpop_proof"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::UseDPoPNonce), + "use_dpop_nonce"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthTokenError::UnsupportedTokenType), + "unsupported_token_type"); +} + +TEST(parse_token_error_codes) { + const auto invalid_request{ + sourcemeta::core::to_oauth_token_error("invalid_request")}; + EXPECT_TRUE(invalid_request.has_value()); + EXPECT_TRUE(invalid_request.value() == + sourcemeta::core::OAuthTokenError::InvalidRequest); + const auto invalid_client{ + sourcemeta::core::to_oauth_token_error("invalid_client")}; + EXPECT_TRUE(invalid_client.has_value()); + EXPECT_TRUE(invalid_client.value() == + sourcemeta::core::OAuthTokenError::InvalidClient); + const auto invalid_grant{ + sourcemeta::core::to_oauth_token_error("invalid_grant")}; + EXPECT_TRUE(invalid_grant.has_value()); + EXPECT_TRUE(invalid_grant.value() == + sourcemeta::core::OAuthTokenError::InvalidGrant); + const auto unauthorized_client{ + sourcemeta::core::to_oauth_token_error("unauthorized_client")}; + EXPECT_TRUE(unauthorized_client.has_value()); + EXPECT_TRUE(unauthorized_client.value() == + sourcemeta::core::OAuthTokenError::UnauthorizedClient); + const auto unsupported_grant_type{ + sourcemeta::core::to_oauth_token_error("unsupported_grant_type")}; + EXPECT_TRUE(unsupported_grant_type.has_value()); + EXPECT_TRUE(unsupported_grant_type.value() == + sourcemeta::core::OAuthTokenError::UnsupportedGrantType); + const auto invalid_scope{ + sourcemeta::core::to_oauth_token_error("invalid_scope")}; + EXPECT_TRUE(invalid_scope.has_value()); + EXPECT_TRUE(invalid_scope.value() == + sourcemeta::core::OAuthTokenError::InvalidScope); + const auto authorization_pending{ + sourcemeta::core::to_oauth_token_error("authorization_pending")}; + EXPECT_TRUE(authorization_pending.has_value()); + EXPECT_TRUE(authorization_pending.value() == + sourcemeta::core::OAuthTokenError::AuthorizationPending); + const auto slow_down{sourcemeta::core::to_oauth_token_error("slow_down")}; + EXPECT_TRUE(slow_down.has_value()); + EXPECT_TRUE(slow_down.value() == sourcemeta::core::OAuthTokenError::SlowDown); + const auto access_denied{ + sourcemeta::core::to_oauth_token_error("access_denied")}; + EXPECT_TRUE(access_denied.has_value()); + EXPECT_TRUE(access_denied.value() == + sourcemeta::core::OAuthTokenError::AccessDenied); + const auto expired_token{ + sourcemeta::core::to_oauth_token_error("expired_token")}; + EXPECT_TRUE(expired_token.has_value()); + EXPECT_TRUE(expired_token.value() == + sourcemeta::core::OAuthTokenError::ExpiredToken); + const auto invalid_target{ + sourcemeta::core::to_oauth_token_error("invalid_target")}; + EXPECT_TRUE(invalid_target.has_value()); + EXPECT_TRUE(invalid_target.value() == + sourcemeta::core::OAuthTokenError::InvalidTarget); + const auto invalid_dpop_proof{ + sourcemeta::core::to_oauth_token_error("invalid_dpop_proof")}; + EXPECT_TRUE(invalid_dpop_proof.has_value()); + EXPECT_TRUE(invalid_dpop_proof.value() == + sourcemeta::core::OAuthTokenError::InvalidDPoPProof); + const auto use_dpop_nonce{ + sourcemeta::core::to_oauth_token_error("use_dpop_nonce")}; + EXPECT_TRUE(use_dpop_nonce.has_value()); + EXPECT_TRUE(use_dpop_nonce.value() == + sourcemeta::core::OAuthTokenError::UseDPoPNonce); + const auto unsupported_token_type{ + sourcemeta::core::to_oauth_token_error("unsupported_token_type")}; + EXPECT_TRUE(unsupported_token_type.has_value()); + EXPECT_TRUE(unsupported_token_type.value() == + sourcemeta::core::OAuthTokenError::UnsupportedTokenType); +} + +TEST(parse_token_error_rejects_an_unknown_code) { + EXPECT_FALSE(sourcemeta::core::to_oauth_token_error("temporarily_unavailable") + .has_value()); + EXPECT_FALSE(sourcemeta::core::to_oauth_token_error("").has_value()); +} + +TEST(serialize_bearer_error_codes) { + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthBearerError::InvalidRequest), + "invalid_request"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthBearerError::InvalidToken), + "invalid_token"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthBearerError::InsufficientScope), + "insufficient_scope"); +} + +TEST(parse_bearer_error_codes) { + const auto invalid_request{ + sourcemeta::core::to_oauth_bearer_error("invalid_request")}; + EXPECT_TRUE(invalid_request.has_value()); + EXPECT_TRUE(invalid_request.value() == + sourcemeta::core::OAuthBearerError::InvalidRequest); + const auto invalid_token{ + sourcemeta::core::to_oauth_bearer_error("invalid_token")}; + EXPECT_TRUE(invalid_token.has_value()); + EXPECT_TRUE(invalid_token.value() == + sourcemeta::core::OAuthBearerError::InvalidToken); + const auto insufficient_scope{ + sourcemeta::core::to_oauth_bearer_error("insufficient_scope")}; + EXPECT_TRUE(insufficient_scope.has_value()); + EXPECT_TRUE(insufficient_scope.value() == + sourcemeta::core::OAuthBearerError::InsufficientScope); +} + +TEST(parse_bearer_error_rejects_an_unknown_code) { + EXPECT_FALSE( + sourcemeta::core::to_oauth_bearer_error("invalid_grant").has_value()); + EXPECT_FALSE(sourcemeta::core::to_oauth_bearer_error("").has_value()); +} + +TEST(serialize_registration_error_codes) { + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthRegistrationError::InvalidRedirectURI), + "invalid_redirect_uri"); + EXPECT_EQ( + sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthRegistrationError::InvalidClientMetadata), + "invalid_client_metadata"); + EXPECT_EQ( + sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthRegistrationError::InvalidSoftwareStatement), + "invalid_software_statement"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthRegistrationError:: + UnapprovedSoftwareStatement), + "unapproved_software_statement"); +} + +TEST(parse_registration_error_codes) { + const auto invalid_redirect_uri{ + sourcemeta::core::to_oauth_registration_error("invalid_redirect_uri")}; + EXPECT_TRUE(invalid_redirect_uri.has_value()); + EXPECT_TRUE(invalid_redirect_uri.value() == + sourcemeta::core::OAuthRegistrationError::InvalidRedirectURI); + const auto invalid_client_metadata{ + sourcemeta::core::to_oauth_registration_error("invalid_client_metadata")}; + EXPECT_TRUE(invalid_client_metadata.has_value()); + EXPECT_TRUE(invalid_client_metadata.value() == + sourcemeta::core::OAuthRegistrationError::InvalidClientMetadata); + const auto invalid_software_statement{ + sourcemeta::core::to_oauth_registration_error( + "invalid_software_statement")}; + EXPECT_TRUE(invalid_software_statement.has_value()); + EXPECT_TRUE( + invalid_software_statement.value() == + sourcemeta::core::OAuthRegistrationError::InvalidSoftwareStatement); + const auto unapproved_software_statement{ + sourcemeta::core::to_oauth_registration_error( + "unapproved_software_statement")}; + EXPECT_TRUE(unapproved_software_statement.has_value()); + EXPECT_TRUE( + unapproved_software_statement.value() == + sourcemeta::core::OAuthRegistrationError::UnapprovedSoftwareStatement); +} + +TEST(parse_registration_error_rejects_an_unknown_code) { + EXPECT_FALSE(sourcemeta::core::to_oauth_registration_error("invalid_request") + .has_value()); + EXPECT_FALSE(sourcemeta::core::to_oauth_registration_error("").has_value()); +} + +TEST(token_error_status_is_401_for_header_client_authentication_failure) { + EXPECT_EQ(sourcemeta::core::oauth_token_error_status( + sourcemeta::core::OAuthTokenError::InvalidClient, true), + 401); +} + +TEST(token_error_status_is_400_for_body_client_authentication_failure) { + EXPECT_EQ(sourcemeta::core::oauth_token_error_status( + sourcemeta::core::OAuthTokenError::InvalidClient, false), + 400); +} + +TEST(token_error_status_is_400_for_other_header_errors) { + EXPECT_EQ(sourcemeta::core::oauth_token_error_status( + sourcemeta::core::OAuthTokenError::InvalidGrant, true), + 400); +} diff --git a/test/oauth/oauth_pkce_test.cc b/test/oauth/oauth_pkce_test.cc new file mode 100644 index 000000000..cd78d71ac --- /dev/null +++ b/test/oauth/oauth_pkce_test.cc @@ -0,0 +1,211 @@ +#include +#include + +#include // std::string +#include // std::string_view + +// RFC 7636 Appendix B: the worked example verifier and its S256 challenge. +static constexpr std::string_view APPENDIX_B_VERIFIER{ + "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"}; +static constexpr std::string_view APPENDIX_B_CHALLENGE{ + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"}; + +// A second valid verifier, distinct from the Appendix B one, for mismatch +// coverage (43 unreserved characters). +static constexpr std::string_view OTHER_VERIFIER{ + "Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyMDA"}; + +TEST(challenge_of_rfc7636_appendix_b) { + const auto challenge{ + sourcemeta::core::oauth_pkce_challenge(APPENDIX_B_VERIFIER)}; + EXPECT_EQ((std::string_view{challenge.data(), challenge.size()}), + APPENDIX_B_CHALLENGE); +} + +TEST(minted_verifier_and_challenge_round_trip) { + const auto verifier{sourcemeta::core::oauth_pkce_verifier()}; + const std::string_view verifier_view{verifier.data(), verifier.size()}; + const auto challenge{sourcemeta::core::oauth_pkce_challenge(verifier_view)}; + const std::string_view challenge_view{challenge.data(), challenge.size()}; + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + verifier_view, challenge_view, + sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::Match); +} + +TEST(minted_verifiers_differ_between_calls) { + const auto first{sourcemeta::core::oauth_pkce_verifier()}; + const auto second{sourcemeta::core::oauth_pkce_verifier()}; + EXPECT_FALSE((std::string_view{first.data(), first.size()}) == + (std::string_view{second.data(), second.size()})); +} + +TEST(verify_matches_rfc7636_appendix_b_under_strict) { + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + APPENDIX_B_VERIFIER, APPENDIX_B_CHALLENGE, + sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::Match); +} + +TEST(verify_rejects_a_mismatched_verifier) { + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + OTHER_VERIFIER, APPENDIX_B_CHALLENGE, + sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::Mismatch); +} + +TEST(verify_reports_a_missing_verifier) { + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + "", APPENDIX_B_CHALLENGE, + sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::MissingVerifier); +} + +TEST(verify_reports_a_missing_challenge) { + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + APPENDIX_B_VERIFIER, "", + sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::MissingChallenge); +} + +TEST(verify_reports_not_used_when_neither_is_present) { + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + "", "", sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::NotUsed); +} + +TEST(verify_rejects_plain_under_strict) { + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + APPENDIX_B_VERIFIER, APPENDIX_B_VERIFIER, + sourcemeta::core::OAuthPKCEMethod::Plain, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::MethodNotAllowed); +} + +TEST(verify_accepts_plain_under_compatible) { + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + APPENDIX_B_VERIFIER, APPENDIX_B_VERIFIER, + sourcemeta::core::OAuthPKCEMethod::Plain, + sourcemeta::core::OAuthProfile::Compatible) == + sourcemeta::core::OAuthPKCEOutcome::Match); +} + +TEST(verify_rejects_a_plain_mismatch_under_compatible) { + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + APPENDIX_B_VERIFIER, OTHER_VERIFIER, + sourcemeta::core::OAuthPKCEMethod::Plain, + sourcemeta::core::OAuthProfile::Compatible) == + sourcemeta::core::OAuthPKCEOutcome::Mismatch); +} + +TEST(verify_rejects_a_malformed_verifier) { + // A 42 character value is one short of the minimum length (RFC 7636 + // Section 4.1). + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjX", + APPENDIX_B_CHALLENGE, sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::MalformedVerifier); +} + +TEST(verify_rejects_an_s256_challenge_of_the_wrong_length) { + // A valid S256 challenge is always exactly 43 characters (RFC 7636 + // Section 4.2), so a 44 character value cannot be one. + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + APPENDIX_B_VERIFIER, + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cMA", + sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::MalformedChallenge); +} + +TEST(verify_rejects_a_verifier_with_an_invalid_character) { + // 43 characters, but the "+" is outside the RFC 3986 unreserved set the + // verifier draws from (RFC 7636 Section 4.1). + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + "dBjftJeZ4CVP+mB92K27uhbUJU1p1r_wW1gFWFOEjXk", + APPENDIX_B_CHALLENGE, sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::MalformedVerifier); +} + +TEST(verify_rejects_an_s256_challenge_with_an_invalid_character) { + // 43 characters, but "+" is not in the unreserved set (RFC 7636 Section 4.3). + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + APPENDIX_B_VERIFIER, + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-c+", + sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::MalformedChallenge); +} + +TEST(verify_treats_an_unreserved_non_base64url_s256_challenge_as_a_mismatch) { + // The final "." is unreserved, so the challenge is a syntactically valid code + // challenge that simply cannot match the S256 output, which is a Mismatch + // rather than a MalformedChallenge. + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + APPENDIX_B_VERIFIER, + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-c.", + sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::Mismatch); +} + +TEST(verify_rejects_a_malformed_plain_challenge_under_compatible) { + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + APPENDIX_B_VERIFIER, + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-c+", + sourcemeta::core::OAuthPKCEMethod::Plain, + sourcemeta::core::OAuthProfile::Compatible) == + sourcemeta::core::OAuthPKCEOutcome::MalformedChallenge); +} + +TEST(verify_accepts_a_verifier_at_the_maximum_length) { + const std::string verifier(128, 'a'); + const auto challenge{sourcemeta::core::oauth_pkce_challenge(verifier)}; + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + verifier, + std::string_view{challenge.data(), challenge.size()}, + sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::Match); +} + +TEST(verify_rejects_a_verifier_over_the_maximum_length) { + const std::string verifier(129, 'a'); + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + verifier, APPENDIX_B_CHALLENGE, + sourcemeta::core::OAuthPKCEMethod::S256, + sourcemeta::core::OAuthProfile::Strict) == + sourcemeta::core::OAuthPKCEOutcome::MalformedVerifier); +} + +TEST(serialize_pkce_methods) { + EXPECT_EQ(sourcemeta::core::oauth_pkce_method_code( + sourcemeta::core::OAuthPKCEMethod::S256), + "S256"); + EXPECT_EQ(sourcemeta::core::oauth_pkce_method_code( + sourcemeta::core::OAuthPKCEMethod::Plain), + "plain"); +} + +TEST(parse_pkce_methods) { + const auto s256{sourcemeta::core::to_oauth_pkce_method("S256")}; + EXPECT_TRUE(s256.has_value()); + EXPECT_TRUE(s256.value() == sourcemeta::core::OAuthPKCEMethod::S256); + const auto plain{sourcemeta::core::to_oauth_pkce_method("plain")}; + EXPECT_TRUE(plain.has_value()); + EXPECT_TRUE(plain.value() == sourcemeta::core::OAuthPKCEMethod::Plain); +} + +TEST(parse_pkce_method_rejects_an_unknown_value) { + EXPECT_FALSE(sourcemeta::core::to_oauth_pkce_method("plaintext").has_value()); + EXPECT_FALSE(sourcemeta::core::to_oauth_pkce_method("s256").has_value()); + EXPECT_FALSE(sourcemeta::core::to_oauth_pkce_method("").has_value()); +} From 07a7be352b7eb6effeca84e7dbe08a9d563d0e9a Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 19 Jul 2026 19:44:49 -0300 Subject: [PATCH 2/5] More Signed-off-by: Juan Cruz Viotti --- src/core/oauth/CMakeLists.txt | 2 +- .../include/sourcemeta/core/oauth_error.h | 22 +++++++++++-------- src/core/oauth/oauth_error.cc | 7 +++--- test/oauth/oauth_error_test.cc | 17 ++++++++------ 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/src/core/oauth/CMakeLists.txt b/src/core/oauth/CMakeLists.txt index 5553af053..f3fe1e318 100644 --- a/src/core/oauth/CMakeLists.txt +++ b/src/core/oauth/CMakeLists.txt @@ -9,7 +9,7 @@ target_link_libraries(sourcemeta_core_oauth target_link_libraries(sourcemeta_core_oauth PUBLIC sourcemeta::core::jose) target_link_libraries(sourcemeta_core_oauth - PRIVATE sourcemeta::core::http) + PUBLIC sourcemeta::core::http) target_link_libraries(sourcemeta_core_oauth PRIVATE sourcemeta::core::uri) target_link_libraries(sourcemeta_core_oauth diff --git a/src/core/oauth/include/sourcemeta/core/oauth_error.h b/src/core/oauth/include/sourcemeta/core/oauth_error.h index 518404568..883594ae0 100644 --- a/src/core/oauth/include/sourcemeta/core/oauth_error.h +++ b/src/core/oauth/include/sourcemeta/core/oauth_error.h @@ -5,7 +5,9 @@ #include #endif -#include // std::uint8_t, std::uint16_t +#include + +#include // std::uint8_t #include // std::exception #include // std::optional #include // std::string_view @@ -231,25 +233,27 @@ auto to_oauth_registration_error(const std::string_view code) noexcept -> std::optional; /// @ingroup oauth -/// The HTTP status code for a token endpoint error response. It is 400 in -/// general, but 401 for a client authentication failure when the client -/// authenticated through the `Authorization` header, since that response must -/// then carry a `WWW-Authenticate` challenge (RFC 6749 Section 5.2). For -/// example: +/// The HTTP status for a token endpoint error response. It is 400 Bad Request +/// in general, but 401 Unauthorized for a client authentication failure when +/// the client authenticated through the `Authorization` header, since that +/// response must then carry a `WWW-Authenticate` challenge (RFC 6749 +/// Section 5.2). For example: /// /// ```cpp /// #include /// #include /// /// assert(sourcemeta::core::oauth_token_error_status( -/// sourcemeta::core::OAuthTokenError::InvalidClient, true) == 401); +/// sourcemeta::core::OAuthTokenError::InvalidClient, true).code == +/// 401); /// assert(sourcemeta::core::oauth_token_error_status( -/// sourcemeta::core::OAuthTokenError::InvalidGrant, true) == 400); +/// sourcemeta::core::OAuthTokenError::InvalidGrant, true).code == +/// 400); /// ``` SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_token_error_status(const OAuthTokenError error, const bool authenticated_via_header) noexcept - -> std::uint16_t; + -> HTTPStatus; #if defined(_MSC_VER) #pragma warning(disable : 4251 4275) diff --git a/src/core/oauth/oauth_error.cc b/src/core/oauth/oauth_error.cc index a6a7b2834..1952e8203 100644 --- a/src/core/oauth/oauth_error.cc +++ b/src/core/oauth/oauth_error.cc @@ -1,6 +1,5 @@ #include -#include // std::uint16_t #include // std::optional, std::nullopt #include // std::string_view #include // std::unreachable @@ -181,16 +180,16 @@ auto to_oauth_registration_error(const std::string_view code) noexcept auto oauth_token_error_status(const OAuthTokenError error, const bool authenticated_via_header) noexcept - -> std::uint16_t { + -> HTTPStatus { // RFC 6749 Section 5.2: "If the client attempted to authenticate via the // 'Authorization' request header field, the authorization server MUST respond // with an HTTP 401 (Unauthorized) status code and include the // WWW-Authenticate response header field" if (error == OAuthTokenError::InvalidClient && authenticated_via_header) { - return 401; + return HTTP_STATUS_UNAUTHORIZED; } - return 400; + return HTTP_STATUS_BAD_REQUEST; } } // namespace sourcemeta::core diff --git a/test/oauth/oauth_error_test.cc b/test/oauth/oauth_error_test.cc index d6272e627..5990ed232 100644 --- a/test/oauth/oauth_error_test.cc +++ b/test/oauth/oauth_error_test.cc @@ -285,19 +285,22 @@ TEST(parse_registration_error_rejects_an_unknown_code) { } TEST(token_error_status_is_401_for_header_client_authentication_failure) { - EXPECT_EQ(sourcemeta::core::oauth_token_error_status( - sourcemeta::core::OAuthTokenError::InvalidClient, true), - 401); + const auto status{sourcemeta::core::oauth_token_error_status( + sourcemeta::core::OAuthTokenError::InvalidClient, true)}; + EXPECT_EQ(status.code, 401); + EXPECT_EQ(status.phrase, "Unauthorized"); } TEST(token_error_status_is_400_for_body_client_authentication_failure) { - EXPECT_EQ(sourcemeta::core::oauth_token_error_status( - sourcemeta::core::OAuthTokenError::InvalidClient, false), - 400); + const auto status{sourcemeta::core::oauth_token_error_status( + sourcemeta::core::OAuthTokenError::InvalidClient, false)}; + EXPECT_EQ(status.code, 400); + EXPECT_EQ(status.phrase, "Bad Request"); } TEST(token_error_status_is_400_for_other_header_errors) { EXPECT_EQ(sourcemeta::core::oauth_token_error_status( - sourcemeta::core::OAuthTokenError::InvalidGrant, true), + sourcemeta::core::OAuthTokenError::InvalidGrant, true) + .code, 400); } From b3748e58118e6b87f0365c459bc6ac2bfb13c304 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 19 Jul 2026 20:11:59 -0300 Subject: [PATCH 3/5] More Signed-off-by: Juan Cruz Viotti --- config.cmake.in | 1 + .../include/sourcemeta/core/oauth_bearer.h | 23 +-- .../include/sourcemeta/core/oauth_error.h | 9 +- .../include/sourcemeta/core/oauth_pkce.h | 13 +- .../include/sourcemeta/core/oauth_profile.h | 7 +- src/core/oauth/oauth_bearer.cc | 52 +++-- src/core/oauth/oauth_error.cc | 8 + src/core/oauth/oauth_pkce.cc | 19 +- test/oauth/oauth_bearer_test.cc | 177 +++++++++--------- test/oauth/oauth_error_test.cc | 16 ++ 10 files changed, 187 insertions(+), 138 deletions(-) diff --git a/config.cmake.in b/config.cmake.in index 9897dc11d..fc7845b14 100644 --- a/config.cmake.in +++ b/config.cmake.in @@ -243,6 +243,7 @@ foreach(component ${SOURCEMETA_CORE_COMPONENTS}) include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_text.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_time.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_json.cmake") + include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_ip.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_uri.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_crypto.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sourcemeta_core_jose.cmake") diff --git a/src/core/oauth/include/sourcemeta/core/oauth_bearer.h b/src/core/oauth/include/sourcemeta/core/oauth_bearer.h index 0996c6356..a137c3d00 100644 --- a/src/core/oauth/include/sourcemeta/core/oauth_bearer.h +++ b/src/core/oauth/include/sourcemeta/core/oauth_bearer.h @@ -14,9 +14,8 @@ namespace sourcemeta::core { /// @ingroup oauth /// Append a `Bearer` credential (RFC 6750 Section 2.1) for an access token to /// the sink, returning whether the token is a well-formed `b64token`. Nothing -/// is appended when it is not. The result carries the access token, which is -/// secret, so a caller that keeps it should hold it in wiping storage. For -/// example: +/// is appended when it is not. The sink then holds the access token, which is +/// secret, so a caller that keeps it should use wiping storage. For example: /// /// ```cpp /// #include @@ -36,30 +35,24 @@ auto oauth_bearer_header(const std::string_view token, std::string &sink) /// scheme in a `WWW-Authenticate` header value (RFC 7235 Section 4.1), /// returning no value when the scheme or parameter is absent, or the header is /// malformed before the parameter is reached. The scheme and parameter name are -/// matched case insensitively. A -/// quoted-string value is unescaped (RFC 9110 Section 5.6.4) into the storage -/// arena and the result borrows from it, otherwise the result borrows from the -/// input. The full grammar is parsed so that adjacent challenges and parameters -/// are told apart correctly. For example: +/// matched case insensitively, and a quoted-string value is unescaped (RFC 9110 +/// Section 5.6.4). The full grammar is parsed so that adjacent challenges and +/// parameters are told apart correctly. For example: /// /// ```cpp /// #include /// #include -/// #include /// -/// std::string storage; /// const auto realm{sourcemeta::core::oauth_challenge_parameter( -/// R"(Bearer realm="example", error="invalid_token")", "Bearer", "realm", -/// storage)}; +/// R"(Bearer realm="example", error="invalid_token")", "Bearer", "realm")}; /// assert(realm.has_value()); /// assert(realm.value() == "example"); /// ``` SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_challenge_parameter(const std::string_view header, const std::string_view scheme, - const std::string_view name, - std::string &storage) - -> std::optional; + const std::string_view name) + -> std::optional; } // namespace sourcemeta::core diff --git a/src/core/oauth/include/sourcemeta/core/oauth_error.h b/src/core/oauth/include/sourcemeta/core/oauth_error.h index 883594ae0..7a1402506 100644 --- a/src/core/oauth/include/sourcemeta/core/oauth_error.h +++ b/src/core/oauth/include/sourcemeta/core/oauth_error.h @@ -94,7 +94,14 @@ enum class OAuthBearerError : std::uint8_t { InvalidToken, /// The token does not carry the scope the request requires (RFC 6750 /// Section 3.1). - InsufficientScope + InsufficientScope, + /// The DPoP proof accompanying the request is missing or invalid, returned + /// only under the `DPoP` scheme (RFC 9449 Section 7.1). + InvalidDPoPProof, + /// The request must be retried with a DPoP proof that carries a nonce the + /// resource server supplies, returned only under the `DPoP` scheme (RFC 9449 + /// Section 7.2). + UseDPoPNonce }; /// @ingroup oauth diff --git a/src/core/oauth/include/sourcemeta/core/oauth_pkce.h b/src/core/oauth/include/sourcemeta/core/oauth_pkce.h index 8089a3804..81347392d 100644 --- a/src/core/oauth/include/sourcemeta/core/oauth_pkce.h +++ b/src/core/oauth/include/sourcemeta/core/oauth_pkce.h @@ -91,15 +91,19 @@ auto to_oauth_pkce_method(const std::string_view value) noexcept /// @ingroup oauth /// Mint a new PKCE code verifier, the base64url encoding of 32 -/// cryptographically random octets (RFC 7636 Section 4.1). The result is secret -/// material, so wipe it once the exchange completes. For example: +/// cryptographically random octets (RFC 7636 Section 4.1). The 43 bytes are not +/// null terminated, so pass them onward as a view of `data()` and `size()` +/// rather than as a C string. The result is secret material, so wipe it once +/// the exchange completes. For example: /// /// ```cpp /// #include /// #include +/// #include /// /// const auto verifier{sourcemeta::core::oauth_pkce_verifier()}; -/// assert(verifier.size() == 43); +/// const std::string_view view{verifier.data(), verifier.size()}; +/// assert(view.size() == 43); /// ``` SOURCEMETA_CORE_OAUTH_EXPORT auto oauth_pkce_verifier() -> std::array; @@ -126,7 +130,8 @@ auto oauth_pkce_challenge(const std::string_view verifier) /// Verify a presented code verifier against a stored code challenge and its /// method, under the given profile, returning the pairing outcome. An empty /// verifier or challenge means the value is absent. The final verifier against -/// challenge comparison is constant time. For example: +/// challenge comparison is constant time over their bytes once their lengths +/// match. For example: /// /// ```cpp /// #include diff --git a/src/core/oauth/include/sourcemeta/core/oauth_profile.h b/src/core/oauth/include/sourcemeta/core/oauth_profile.h index 87c28a17c..8538920bd 100644 --- a/src/core/oauth/include/sourcemeta/core/oauth_profile.h +++ b/src/core/oauth/include/sourcemeta/core/oauth_profile.h @@ -21,10 +21,11 @@ namespace sourcemeta::core { /// const auto profile{sourcemeta::core::OAuthProfile::Strict}; /// ``` enum class OAuthProfile : std::uint8_t { - /// The default. Rejects the `plain` PKCE method, requires exact redirect URI - /// matching, and refuses bearer tokens outside the `Authorization` header. + /// The default, selecting the OAuth 2.1 and RFC 9700 hardening. Each function + /// that takes a profile applies the part of that hardening it governs. Today + /// that is the PKCE method check, where the strict profile rejects `plain`. Strict, - /// Accepts the RFC 6749 behaviours the strict profile refuses, for + /// Selects the RFC 6749 behaviours the strict profile refuses, for /// interoperability with deployments that predate the hardening. Compatible }; diff --git a/src/core/oauth/oauth_bearer.cc b/src/core/oauth/oauth_bearer.cc index 020e54465..eef457478 100644 --- a/src/core/oauth/oauth_bearer.cc +++ b/src/core/oauth/oauth_bearer.cc @@ -38,29 +38,46 @@ auto oauth_bearer_header(const std::string_view token, std::string &sink) auto oauth_challenge_parameter(const std::string_view header, const std::string_view scheme, - const std::string_view name, - std::string &storage) - -> std::optional { - // Reserving the whole input up front means the unescaping below never - // reallocates storage, so a value it returns stays valid while the scan - // continues - storage.reserve(storage.size() + header.size()); + const std::string_view name) + -> std::optional { + // A local arena holds the unescaped bytes of a quoted value. The header is + // only ever read, never grown, so it can never alias the arena, and the + // matched value is returned by copy before the arena goes out of scope + std::string arena; std::size_t position{0}; // The scheme of the challenge currently being read. RFC 7235 shares the comma // between the challenge list and each challenge's parameter list, so a token // is a new scheme unless it is immediately followed by "=", in which case it // is a parameter of the current challenge std::string_view current_scheme{}; + // Whether the current challenge has already taken a parameter, and whether + // the next element must be preceded by a comma. RFC 9110 Section 5.6.1 + // separates list elements with commas, so a parameter or challenge that + // follows another without one is malformed rather than silently reassociated + bool scheme_has_parameter{false}; + bool separator_required{false}; while (position < header.size()) { - // RFC 9110 Section 5.6.1 list rule: skip optional whitespace and the commas - // that separate elements, tolerating empty elements - while (position < header.size() && - (http_is_ows(header[position]) || header[position] == ',')) { + position = oauth_skip_ows(header, position); + if (position >= header.size()) { + break; + } + + if (header[position] == ',') { position += 1; + separator_required = false; + // A comma before any parameter ends an empty challenge, so a parameter + // that follows cannot attach to that scheme + if (!scheme_has_parameter) { + current_scheme = {}; + } + + continue; } - if (position >= header.size()) { - break; + // The element is not a comma, so if one was required the header is + // malformed + if (separator_required) { + return std::nullopt; } const std::size_t token_start{position}; @@ -85,8 +102,9 @@ auto oauth_challenge_parameter(const std::string_view header, equals_ignore_case(token, name)}; std::string_view value{}; if (position < header.size() && header[position] == '"') { + arena.clear(); const auto next{ - http_scan_quoted_string(header, position, storage, value)}; + http_scan_quoted_string(header, position, arena, value)}; if (!next.has_value()) { return std::nullopt; } @@ -105,8 +123,10 @@ auto oauth_challenge_parameter(const std::string_view header, value = header.substr(value_start, position - value_start); } + scheme_has_parameter = true; + separator_required = true; if (match) { - return value; + return std::string{value}; } continue; @@ -115,6 +135,7 @@ auto oauth_challenge_parameter(const std::string_view header, // The token is a new scheme. It may be followed by a token68 credential, // which is skipped whole so the scan reaches the next challenge current_scheme = token; + scheme_has_parameter = false; const auto separator{oauth_skip_ows(header, after_token)}; if (separator > after_token && separator < header.size()) { std::size_t blob{separator}; @@ -134,6 +155,7 @@ auto oauth_challenge_parameter(const std::string_view header, http_is_b64token(header.substr(separator, blob - separator))) { position = blob; current_scheme = {}; + separator_required = true; } } } diff --git a/src/core/oauth/oauth_error.cc b/src/core/oauth/oauth_error.cc index 1952e8203..861126d4d 100644 --- a/src/core/oauth/oauth_error.cc +++ b/src/core/oauth/oauth_error.cc @@ -73,6 +73,10 @@ auto oauth_error_code(const OAuthBearerError error) noexcept return "invalid_token"; case OAuthBearerError::InsufficientScope: return "insufficient_scope"; + case OAuthBearerError::InvalidDPoPProof: + return "invalid_dpop_proof"; + case OAuthBearerError::UseDPoPNonce: + return "use_dpop_nonce"; } std::unreachable(); @@ -158,6 +162,10 @@ auto to_oauth_bearer_error(const std::string_view code) noexcept return OAuthBearerError::InvalidToken; } else if (code == "insufficient_scope") { return OAuthBearerError::InsufficientScope; + } else if (code == "invalid_dpop_proof") { + return OAuthBearerError::InvalidDPoPProof; + } else if (code == "use_dpop_nonce") { + return OAuthBearerError::UseDPoPNonce; } else { return std::nullopt; } diff --git a/src/core/oauth/oauth_pkce.cc b/src/core/oauth/oauth_pkce.cc index bb2747bd6..68b049e1b 100644 --- a/src/core/oauth/oauth_pkce.cc +++ b/src/core/oauth/oauth_pkce.cc @@ -10,6 +10,7 @@ #include // std::uint8_t #include // std::optional, std::nullopt #include // std::span +#include // std::string #include // std::string_view #include // std::unreachable @@ -40,16 +41,16 @@ auto to_oauth_pkce_method(const std::string_view value) noexcept auto oauth_pkce_verifier() -> std::array { // The verifier is the base64url of 32 random octets, so both the entropy and - // the encoded form are secret and are wiped before returning. The caller owns - // the returned value and is responsible for wiping it in turn - std::array entropy{}; - random_bytes(std::span{entropy}); + // the encoded form are secret. The scope guard wipes the entropy on every + // exit, including an exception while encoding. The caller owns the returned + // value and is responsible for wiping it in turn + std::string entropy(32, '\x00'); + const SecureStringScope entropy_scope{entropy}; + random_bytes( + std::span{reinterpret_cast(entropy.data()), + entropy.size()}); // NOLINT SecureString encoded; - base64url_encode(std::string_view{reinterpret_cast( // NOLINT - entropy.data()), - entropy.size()}, - encoded); - secure_zero(entropy.data(), entropy.size()); + base64url_encode(entropy, encoded); std::array result{}; std::ranges::copy(std::string_view{encoded}, result.begin()); diff --git a/test/oauth/oauth_bearer_test.cc b/test/oauth/oauth_bearer_test.cc index 7f881c779..15cdecce3 100644 --- a/test/oauth/oauth_bearer_test.cc +++ b/test/oauth/oauth_bearer_test.cc @@ -43,133 +43,113 @@ TEST(build_then_http_parse_round_trips) { } TEST(challenge_parameter_finds_a_quoted_value) { - std::string storage; const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="example")", "Bearer", "realm", storage)}; + R"(Bearer realm="example")", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), "example"); } TEST(challenge_parameter_finds_a_bare_token_value) { - std::string storage; const auto error{sourcemeta::core::oauth_challenge_parameter( - "Bearer error=invalid_token", "Bearer", "error", storage)}; + "Bearer error=invalid_token", "Bearer", "error")}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), "invalid_token"); } TEST(challenge_parameter_finds_a_later_parameter_of_the_same_challenge) { - std::string storage; const auto error{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="example", error="invalid_token")", "Bearer", "error", - storage)}; + R"(Bearer realm="example", error="invalid_token")", "Bearer", "error")}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), "invalid_token"); } TEST(challenge_parameter_matches_the_scheme_and_name_case_insensitively) { - std::string storage; const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(bearer REALM="example")", "Bearer", "realm", storage)}; + R"(bearer REALM="example")", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), "example"); } TEST(challenge_parameter_tolerates_bad_whitespace_around_equals) { - std::string storage; const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm = "example")", "Bearer", "realm", storage)}; + R"(Bearer realm = "example")", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), "example"); } TEST(challenge_parameter_selects_the_named_scheme) { - std::string storage; const auto algs{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="example", DPoP algs="ES256 PS256")", "DPoP", "algs", - storage)}; + R"(Bearer realm="example", DPoP algs="ES256 PS256")", "DPoP", "algs")}; EXPECT_TRUE(algs.has_value()); EXPECT_EQ(algs.value(), "ES256 PS256"); } TEST(challenge_parameter_does_not_cross_schemes) { - std::string storage; // realm belongs to Bearer, not to DPoP, so a lookup on DPoP does not find it const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="example", DPoP algs="ES256")", "DPoP", "realm", - storage)}; + R"(Bearer realm="example", DPoP algs="ES256")", "DPoP", "realm")}; EXPECT_FALSE(realm.has_value()); } TEST(challenge_parameter_handles_an_empty_first_challenge) { - std::string storage; // RFC 9449 Figure 17: a schemes-only Bearer challenge alongside a DPoP one const auto algs{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer, DPoP algs="ES256 PS256")", "DPoP", "algs", storage)}; + R"(Bearer, DPoP algs="ES256 PS256")", "DPoP", "algs")}; EXPECT_TRUE(algs.has_value()); EXPECT_EQ(algs.value(), "ES256 PS256"); } TEST(challenge_parameter_keeps_a_comma_inside_a_quoted_value) { - std::string storage; const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="a,b")", "Bearer", "realm", storage)}; + R"(Bearer realm="a,b")", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), "a,b"); } TEST(challenge_parameter_unescapes_a_quoted_pair) { - std::string storage; const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="say \"hi\"")", "Bearer", "realm", storage)}; + R"(Bearer realm="say \"hi\"")", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), R"(say "hi")"); } TEST(challenge_parameter_tolerates_empty_list_elements) { - std::string storage; const auto point{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="x",, DPoP algs="ES256")", "DPoP", "algs", storage)}; + R"(Bearer realm="x",, DPoP algs="ES256")", "DPoP", "algs")}; EXPECT_TRUE(point.has_value()); EXPECT_EQ(point.value(), "ES256"); } TEST(challenge_parameter_skips_a_token68_challenge) { - std::string storage; const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(Negotiate YWJjZGVm==, Bearer realm="example")", "Bearer", "realm", - storage)}; + R"(Negotiate YWJjZGVm==, Bearer realm="example")", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), "example"); } TEST(challenge_parameter_finds_a_resource_metadata_url) { - std::string storage; const auto url{sourcemeta::core::oauth_challenge_parameter( R"(Bearer resource_metadata="https://rs.example/.well-known/x")", - "Bearer", "resource_metadata", storage)}; + "Bearer", "resource_metadata")}; EXPECT_TRUE(url.has_value()); EXPECT_EQ(url.value(), "https://rs.example/.well-known/x"); } TEST(challenge_parameter_returns_nothing_for_an_absent_parameter) { - std::string storage; EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="example")", "Bearer", "error", storage) + R"(Bearer realm="example")", "Bearer", "error") .has_value()); } TEST(challenge_parameter_returns_nothing_for_an_absent_scheme) { - std::string storage; EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="example")", "DPoP", "algs", storage) + R"(Bearer realm="example")", "DPoP", "algs") .has_value()); } TEST(challenge_parameter_rejects_an_unterminated_quoted_string) { - std::string storage; EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="example)", "Bearer", "realm", storage) + R"(Bearer realm="example)", "Bearer", "realm") .has_value()); } @@ -177,50 +157,43 @@ TEST(challenge_parameter_parses_the_rfc7235_example) { // RFC 7235 Section 4.1 example, adapted with the standard schemes static constexpr std::string_view header{ R"(Newauth realm="apps", type=1, title="Login", Basic realm="simple")"}; - std::string storage; - EXPECT_EQ(sourcemeta::core::oauth_challenge_parameter(header, "Newauth", - "realm", storage) - .value(), - "apps"); - std::string type_storage; - EXPECT_EQ(sourcemeta::core::oauth_challenge_parameter(header, "Newauth", - "type", type_storage) - .value(), - "1"); - std::string title_storage; - EXPECT_EQ(sourcemeta::core::oauth_challenge_parameter(header, "Newauth", - "title", title_storage) - .value(), - "Login"); - std::string basic_storage; - EXPECT_EQ(sourcemeta::core::oauth_challenge_parameter(header, "Basic", - "realm", basic_storage) - .value(), - "simple"); + EXPECT_EQ( + sourcemeta::core::oauth_challenge_parameter(header, "Newauth", "realm") + .value(), + "apps"); + EXPECT_EQ( + sourcemeta::core::oauth_challenge_parameter(header, "Newauth", "type") + .value(), + "1"); + EXPECT_EQ( + sourcemeta::core::oauth_challenge_parameter(header, "Newauth", "title") + .value(), + "Login"); + EXPECT_EQ( + sourcemeta::core::oauth_challenge_parameter(header, "Basic", "realm") + .value(), + "simple"); } TEST(challenge_parameter_survives_earlier_escaped_values_in_the_arena) { // Two escaped parameters are captured into the arena before the match, so the - // returned view must remain valid after those appends - std::string storage; + // returned value must remain correct after those appends const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer a="\A", b="\B", realm="\Z")", "Bearer", "realm", storage)}; + R"(Bearer a="\A", b="\B", realm="\Z")", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), "Z"); } TEST(challenge_parameter_finds_a_later_challenge_of_the_same_scheme) { - std::string storage; const auto error{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="a", Bearer error="b")", "Bearer", "error", storage)}; + R"(Bearer realm="a", Bearer error="b")", "Bearer", "error")}; EXPECT_TRUE(error.has_value()); EXPECT_EQ(error.value(), "b"); } TEST(challenge_parameter_keeps_equals_and_colon_in_a_quoted_value) { - std::string storage; const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="a=b:c")", "Bearer", "realm", storage)}; + R"(Bearer realm="a=b:c")", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), "a=b:c"); } @@ -229,26 +202,23 @@ TEST(challenge_parameter_keeps_obs_text_in_a_quoted_value) { std::string header{R"(Bearer realm=")"}; header.push_back('\x80'); header.append(R"(")"); - std::string storage; - const auto realm{sourcemeta::core::oauth_challenge_parameter( - header, "Bearer", "realm", storage)}; + const auto realm{ + sourcemeta::core::oauth_challenge_parameter(header, "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value().size(), 1); EXPECT_EQ(static_cast(realm.value().front()), 0x80); } TEST(challenge_parameter_skips_an_unpadded_token68_challenge) { - std::string storage; const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(Negotiate abcdef, Bearer realm="x")", "Bearer", "realm", storage)}; + R"(Negotiate abcdef, Bearer realm="x")", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), "x"); } TEST(challenge_parameter_skips_a_token68_with_plus_and_slash) { - std::string storage; const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(Negotiate a/b+c==, Bearer realm="x")", "Bearer", "realm", storage)}; + R"(Negotiate a/b+c==, Bearer realm="x")", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), "x"); } @@ -256,54 +226,47 @@ TEST(challenge_parameter_skips_a_token68_with_plus_and_slash) { TEST(challenge_parameter_rejects_a_padding_only_blob) { // "==" is not a valid token68 (it lacks the required alphabet character), so // the header is malformed - std::string storage; EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( - R"(Foo ==, Bearer realm="x")", "Bearer", "realm", storage) + R"(Foo ==, Bearer realm="x")", "Bearer", "realm") .has_value()); } TEST(challenge_parameter_rejects_a_parameter_after_a_token68) { // A token68 form of a challenge cannot also carry parameters (RFC 7235 // Section 2.1), so the trailing parameter makes the header malformed - std::string storage; EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( R"(Bearer foo, resource_metadata="https://x")", "Bearer", - "resource_metadata", storage) + "resource_metadata") .has_value()); } TEST(challenge_parameter_rejects_a_parameter_before_any_scheme) { - std::string storage; - EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( - R"(realm="x")", "Bearer", "realm", storage) + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter(R"(realm="x")", + "Bearer", "realm") .has_value()); } TEST(challenge_parameter_rejects_an_empty_bare_value) { - std::string storage; EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( - "Bearer realm=", "Bearer", "realm", storage) + "Bearer realm=", "Bearer", "realm") .has_value()); } TEST(challenge_parameter_returns_nothing_for_an_empty_header) { - std::string storage; - EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter("", "Bearer", - "realm", storage) - .has_value()); + EXPECT_FALSE( + sourcemeta::core::oauth_challenge_parameter("", "Bearer", "realm") + .has_value()); } TEST(challenge_parameter_returns_nothing_for_a_whitespace_header) { - std::string storage; - EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter(" ", "Bearer", - "realm", storage) - .has_value()); + EXPECT_FALSE( + sourcemeta::core::oauth_challenge_parameter(" ", "Bearer", "realm") + .has_value()); } TEST(challenge_parameter_tolerates_leading_and_trailing_commas) { - std::string storage; const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(,,Bearer realm="x",,)", "Bearer", "realm", storage)}; + R"(,,Bearer realm="x",,)", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), "x"); } @@ -311,9 +274,41 @@ TEST(challenge_parameter_tolerates_leading_and_trailing_commas) { TEST(challenge_parameter_ignores_malformation_after_the_match) { // The lookup returns as soon as the parameter is found, so trailing garbage // after the matched value does not change the result - std::string storage; const auto realm{sourcemeta::core::oauth_challenge_parameter( - R"(Bearer realm="x" garbage)", "Bearer", "realm", storage)}; + R"(Bearer realm="x" garbage)", "Bearer", "realm")}; EXPECT_TRUE(realm.has_value()); EXPECT_EQ(realm.value(), "x"); } + +TEST(challenge_parameter_finds_an_empty_quoted_value) { + // RFC 9110 Section 5.6.4: a quoted-string may be empty, so an empty value is + // present and distinct from an absent parameter + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="")", "Bearer", "realm")}; + EXPECT_TRUE(realm.has_value()); + EXPECT_TRUE(realm.value().empty()); +} + +TEST(challenge_parameter_rejects_a_parameter_after_a_comma_only_challenge) { + // The comma ends the empty Bearer challenge, so realm cannot attach to it and + // there is no scheme for it to belong to (RFC 9110 Section 5.6.1) + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer, realm="x")", "Bearer", "realm") + .has_value()); +} + +TEST(challenge_parameter_rejects_a_missing_comma_between_parameters) { + // RFC 9110 Section 5.6.1: parameters are comma separated, so a second + // parameter with no comma before it makes the header malformed + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="x" error="y")", "Bearer", "error") + .has_value()); +} + +TEST(challenge_parameter_rejects_a_missing_comma_between_challenges) { + // A second challenge with no comma before it makes the header malformed, so a + // lookup that must reach it finds nothing + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="x" DPoP algs="y")", "DPoP", "algs") + .has_value()); +} diff --git a/test/oauth/oauth_error_test.cc b/test/oauth/oauth_error_test.cc index 5990ed232..113d16836 100644 --- a/test/oauth/oauth_error_test.cc +++ b/test/oauth/oauth_error_test.cc @@ -207,6 +207,12 @@ TEST(serialize_bearer_error_codes) { EXPECT_EQ(sourcemeta::core::oauth_error_code( sourcemeta::core::OAuthBearerError::InsufficientScope), "insufficient_scope"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthBearerError::InvalidDPoPProof), + "invalid_dpop_proof"); + EXPECT_EQ(sourcemeta::core::oauth_error_code( + sourcemeta::core::OAuthBearerError::UseDPoPNonce), + "use_dpop_nonce"); } TEST(parse_bearer_error_codes) { @@ -225,6 +231,16 @@ TEST(parse_bearer_error_codes) { EXPECT_TRUE(insufficient_scope.has_value()); EXPECT_TRUE(insufficient_scope.value() == sourcemeta::core::OAuthBearerError::InsufficientScope); + const auto invalid_dpop_proof{ + sourcemeta::core::to_oauth_bearer_error("invalid_dpop_proof")}; + EXPECT_TRUE(invalid_dpop_proof.has_value()); + EXPECT_TRUE(invalid_dpop_proof.value() == + sourcemeta::core::OAuthBearerError::InvalidDPoPProof); + const auto use_dpop_nonce{ + sourcemeta::core::to_oauth_bearer_error("use_dpop_nonce")}; + EXPECT_TRUE(use_dpop_nonce.has_value()); + EXPECT_TRUE(use_dpop_nonce.value() == + sourcemeta::core::OAuthBearerError::UseDPoPNonce); } TEST(parse_bearer_error_rejects_an_unknown_code) { From 880c57966089e93c7d62bc723b9334a76bc8ae20 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 19 Jul 2026 20:27:05 -0300 Subject: [PATCH 4/5] More Signed-off-by: Juan Cruz Viotti --- .../include/sourcemeta/core/oauth_error.h | 2 +- .../include/sourcemeta/core/oauth_pkce.h | 8 +- src/core/oauth/oauth_syntax.h | 2 +- test/http/http_syntax_test.cc | 38 ++++++++++ test/oauth/oauth_bearer_test.cc | 76 +++++++++++++++++++ test/oauth/oauth_pkce_test.cc | 26 ++++++- 6 files changed, 145 insertions(+), 7 deletions(-) diff --git a/src/core/oauth/include/sourcemeta/core/oauth_error.h b/src/core/oauth/include/sourcemeta/core/oauth_error.h index 7a1402506..040a0a4d7 100644 --- a/src/core/oauth/include/sourcemeta/core/oauth_error.h +++ b/src/core/oauth/include/sourcemeta/core/oauth_error.h @@ -100,7 +100,7 @@ enum class OAuthBearerError : std::uint8_t { InvalidDPoPProof, /// The request must be retried with a DPoP proof that carries a nonce the /// resource server supplies, returned only under the `DPoP` scheme (RFC 9449 - /// Section 7.2). + /// Section 9). UseDPoPNonce }; diff --git a/src/core/oauth/include/sourcemeta/core/oauth_pkce.h b/src/core/oauth/include/sourcemeta/core/oauth_pkce.h index 81347392d..91081f30f 100644 --- a/src/core/oauth/include/sourcemeta/core/oauth_pkce.h +++ b/src/core/oauth/include/sourcemeta/core/oauth_pkce.h @@ -15,13 +15,13 @@ namespace sourcemeta::core { /// @ingroup oauth -/// The PKCE code challenge transformation method (RFC 7636 Section 4.3). +/// The PKCE code challenge transformation method (RFC 7636 Section 4.2). enum class OAuthPKCEMethod : std::uint8_t { /// The SHA-256 transformation, the only method the strict profile permits /// (RFC 7636 Section 4.2). S256, /// The identity transformation, permitted only under the compatible profile - /// (RFC 7636 Section 4.4). + /// (RFC 7636 Section 4.2). Plain }; @@ -54,8 +54,8 @@ enum class OAuthPKCEOutcome : std::uint8_t { MalformedVerifier, /// The stored challenge violates the code challenge syntax, either the wrong /// length for its method or a character outside the allowed set (RFC 7636 - /// Sections 4.2 and 4.3). A syntactically valid challenge that simply does - /// not correspond to the verifier is a `Mismatch`, not this. + /// Section 4.2). A syntactically valid challenge that simply does not + /// correspond to the verifier is a `Mismatch`, not this. MalformedChallenge }; diff --git a/src/core/oauth/oauth_syntax.h b/src/core/oauth/oauth_syntax.h index 057f1698b..e1032654b 100644 --- a/src/core/oauth/oauth_syntax.h +++ b/src/core/oauth/oauth_syntax.h @@ -27,7 +27,7 @@ inline auto oauth_is_pkce_verifier(const std::string_view value) noexcept oauth_is_unreserved_string(value); } -// RFC 7636 Section 4.3: "code-challenge = 43*128unreserved" +// RFC 7636 Section 4.2: "code-challenge = 43*128unreserved" inline auto oauth_is_pkce_challenge(const std::string_view value) noexcept -> bool { return value.size() >= 43 && value.size() <= 128 && diff --git a/test/http/http_syntax_test.cc b/test/http/http_syntax_test.cc index d76c9766e..57754a6e2 100644 --- a/test/http/http_syntax_test.cc +++ b/test/http/http_syntax_test.cc @@ -289,3 +289,41 @@ TEST(scan_quoted_string_accumulates_into_shared_storage) { EXPECT_EQ(first, R"(a"a)"); EXPECT_EQ(second, R"(b"b)"); } + +TEST(scan_quoted_string_unescapes_a_space) { + // RFC 9110 Section 5.6.4: quoted-pair admits SP, so an escaped space is + // literal content + std::string storage; + std::string_view value; + const auto next{sourcemeta::core::http_scan_quoted_string(R"("a\ b")", 0, + storage, value)}; + EXPECT_TRUE(next.has_value()); + EXPECT_EQ(value, "a b"); +} + +TEST(scan_quoted_string_unescapes_a_horizontal_tab) { + // RFC 9110 Section 5.6.4: quoted-pair admits HTAB + std::string storage; + std::string_view value; + std::string input{"\"a\\"}; + input.push_back('\t'); + input.push_back('"'); + const auto next{ + sourcemeta::core::http_scan_quoted_string(input, 0, storage, value)}; + EXPECT_TRUE(next.has_value()); + EXPECT_EQ(value, std::string_view{"a\t"}); +} + +TEST(scan_quoted_string_unescapes_obs_text) { + // RFC 9110 Section 5.6.4: quoted-pair admits obs-text + std::string storage; + std::string_view value; + std::string input{"\"a\\"}; + input.push_back('\x80'); + input.push_back('"'); + const auto next{ + sourcemeta::core::http_scan_quoted_string(input, 0, storage, value)}; + EXPECT_TRUE(next.has_value()); + EXPECT_EQ(value.size(), 2); + EXPECT_EQ(static_cast(value.back()), 0x80); +} diff --git a/test/oauth/oauth_bearer_test.cc b/test/oauth/oauth_bearer_test.cc index 15cdecce3..5ae98ef1a 100644 --- a/test/oauth/oauth_bearer_test.cc +++ b/test/oauth/oauth_bearer_test.cc @@ -312,3 +312,79 @@ TEST(challenge_parameter_rejects_a_missing_comma_between_challenges) { R"(Bearer realm="x" DPoP algs="y")", "DPoP", "algs") .has_value()); } + +TEST(challenge_parameter_rejects_an_escaped_closing_quote) { + // RFC 9110 Section 5.6.4: the backslash forms a quoted-pair with the final + // quote, so the quoted-string is never closed and the header is malformed + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + "Bearer realm=\"abc\\\"", "Bearer", "realm") + .has_value()); +} + +TEST(challenge_parameter_keeps_a_literal_space_in_a_quoted_value) { + // RFC 9110 Section 5.6.4: qdtext admits a bare SP, so a space inside the + // quotes is part of the value rather than a delimiter + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="a b")", "Bearer", "realm")}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "a b"); +} + +TEST(challenge_parameter_finds_a_bare_value_with_token_punctuation) { + // RFC 9110 Section 5.6.2: "!" and "#" are tchar, so they belong to a bare + // token value rather than ending it + const auto error{sourcemeta::core::oauth_challenge_parameter( + "Bearer error=a!b#c", "Bearer", "error")}; + EXPECT_TRUE(error.has_value()); + EXPECT_EQ(error.value(), "a!b#c"); +} + +TEST(challenge_parameter_skips_a_trailing_token68_challenge) { + // RFC 7235 Section 2.1: a token68 challenge that ends the header is skipped + // whole, so a lookup that reaches past it simply finds nothing + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="x", Negotiate YWJj==)", "Bearer", "error") + .has_value()); +} + +TEST(challenge_parameter_tolerates_whitespace_before_a_separating_comma) { + // RFC 9110 Section 5.6.1: optional whitespace may surround the comma, so a + // space between an empty challenge and the comma is harmless + const auto algs{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer , DPoP algs="ES256")", "DPoP", "algs")}; + EXPECT_TRUE(algs.has_value()); + EXPECT_EQ(algs.value(), "ES256"); +} + +TEST(challenge_parameter_tolerates_whitespace_around_a_separating_comma) { + // RFC 9110 Section 5.6.1: optional whitespace may surround the comma between + // two parameters of the same challenge + const auto error{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="x" , error="y")", "Bearer", "error")}; + EXPECT_TRUE(error.has_value()); + EXPECT_EQ(error.value(), "y"); +} + +TEST(challenge_parameter_treats_a_second_scheme_without_a_comma_as_a_scheme) { + // RFC 7235 Section 4.1: a token that is not followed by "=" is a new scheme + // rather than a parameter, so the parameter binds to the second scheme + const auto basic{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer Basic realm="x")", "Basic", "realm")}; + EXPECT_TRUE(basic.has_value()); + EXPECT_EQ(basic.value(), "x"); + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer Basic realm="x")", "Bearer", "realm") + .has_value()); +} + +TEST(challenge_parameter_returns_nothing_for_a_scheme_only_header) { + EXPECT_FALSE( + sourcemeta::core::oauth_challenge_parameter("Bearer", "Bearer", "realm") + .has_value()); +} + +TEST(challenge_parameter_returns_nothing_for_a_comma_only_header) { + EXPECT_FALSE( + sourcemeta::core::oauth_challenge_parameter(" , , ", "Bearer", "realm") + .has_value()); +} diff --git a/test/oauth/oauth_pkce_test.cc b/test/oauth/oauth_pkce_test.cc index cd78d71ac..d467e4d7d 100644 --- a/test/oauth/oauth_pkce_test.cc +++ b/test/oauth/oauth_pkce_test.cc @@ -136,7 +136,7 @@ TEST(verify_rejects_a_verifier_with_an_invalid_character) { } TEST(verify_rejects_an_s256_challenge_with_an_invalid_character) { - // 43 characters, but "+" is not in the unreserved set (RFC 7636 Section 4.3). + // 43 characters, but "+" is not in the unreserved set (RFC 7636 Section 4.2). EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( APPENDIX_B_VERIFIER, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-c+", @@ -166,6 +166,30 @@ TEST(verify_rejects_a_malformed_plain_challenge_under_compatible) { sourcemeta::core::OAuthPKCEOutcome::MalformedChallenge); } +TEST(verify_rejects_a_plain_challenge_of_the_wrong_length) { + // A plain challenge is the verifier itself, so it must also satisfy the + // 43 character minimum (RFC 7636 Section 4.2), and a 42 character value + // cannot + const std::string challenge(42, 'a'); + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + APPENDIX_B_VERIFIER, challenge, + sourcemeta::core::OAuthPKCEMethod::Plain, + sourcemeta::core::OAuthProfile::Compatible) == + sourcemeta::core::OAuthPKCEOutcome::MalformedChallenge); +} + +TEST(verify_accepts_a_verifier_with_all_unreserved_punctuation) { + // RFC 7636 Section 4.1 draws the verifier from the RFC 3986 unreserved set, + // so + // "-", ".", "_", and "~" are all valid, none of which the Appendix B vector + // exercises together + const std::string verifier{"abcdefghij-klmnopqrst.uvwxyzABCDEFG_HIJKL~M"}; + EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( + verifier, verifier, sourcemeta::core::OAuthPKCEMethod::Plain, + sourcemeta::core::OAuthProfile::Compatible) == + sourcemeta::core::OAuthPKCEOutcome::Match); +} + TEST(verify_accepts_a_verifier_at_the_maximum_length) { const std::string verifier(128, 'a'); const auto challenge{sourcemeta::core::oauth_pkce_challenge(verifier)}; From 8f4d7b6f8dce0a0dcc07174bea2f3149f0e952c0 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 19 Jul 2026 20:33:11 -0300 Subject: [PATCH 5/5] More Signed-off-by: Juan Cruz Viotti --- test/oauth/oauth_pkce_test.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/oauth/oauth_pkce_test.cc b/test/oauth/oauth_pkce_test.cc index d467e4d7d..1ab8b7322 100644 --- a/test/oauth/oauth_pkce_test.cc +++ b/test/oauth/oauth_pkce_test.cc @@ -168,8 +168,8 @@ TEST(verify_rejects_a_malformed_plain_challenge_under_compatible) { TEST(verify_rejects_a_plain_challenge_of_the_wrong_length) { // A plain challenge is the verifier itself, so it must also satisfy the - // 43 character minimum (RFC 7636 Section 4.2), and a 42 character value - // cannot + // 43 character minimum (RFC 7636 Section 4.2), which a 42 character value + // fails const std::string challenge(42, 'a'); EXPECT_TRUE(sourcemeta::core::oauth_pkce_verify( APPENDIX_B_VERIFIER, challenge,