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..fc7845b14 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,26 @@ 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_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") + 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..f3fe1e318 --- /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 + PUBLIC 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..a137c3d00 --- /dev/null +++ b/src/core/oauth/include/sourcemeta/core/oauth_bearer.h @@ -0,0 +1,59 @@ +#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 sink then holds the access token, which is +/// secret, so a caller that keeps it should use 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, 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 +/// +/// const auto realm{sourcemeta::core::oauth_challenge_parameter( +/// 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::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..040a0a4d7 --- /dev/null +++ b/src/core/oauth/include/sourcemeta/core/oauth_error.h @@ -0,0 +1,297 @@ +#ifndef SOURCEMETA_CORE_OAUTH_ERROR_H_ +#define SOURCEMETA_CORE_OAUTH_ERROR_H_ + +#ifndef SOURCEMETA_CORE_OAUTH_EXPORT +#include +#endif + +#include + +#include // std::uint8_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, + /// 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 9). + UseDPoPNonce +}; + +/// @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 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).code == +/// 401); +/// assert(sourcemeta::core::oauth_token_error_status( +/// 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 + -> HTTPStatus; + +#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..91081f30f --- /dev/null +++ b/src/core/oauth/include/sourcemeta/core/oauth_pkce.h @@ -0,0 +1,155 @@ +#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.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.2). + 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 + /// Section 4.2). 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 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()}; +/// const std::string_view view{verifier.data(), verifier.size()}; +/// assert(view.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 over their bytes once their lengths +/// match. 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..8538920bd --- /dev/null +++ b/src/core/oauth/include/sourcemeta/core/oauth_profile.h @@ -0,0 +1,35 @@ +#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, 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, + /// Selects 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..eef457478 --- /dev/null +++ b/src/core/oauth/oauth_bearer.cc @@ -0,0 +1,166 @@ +#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::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()) { + 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; + } + + // 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}; + 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] == '"') { + arena.clear(); + const auto next{ + http_scan_quoted_string(header, position, arena, 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); + } + + scheme_has_parameter = true; + separator_required = true; + if (match) { + return std::string{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; + 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}; + 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 = {}; + separator_required = true; + } + } + } + + 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..861126d4d --- /dev/null +++ b/src/core/oauth/oauth_error.cc @@ -0,0 +1,203 @@ +#include + +#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"; + case OAuthBearerError::InvalidDPoPProof: + return "invalid_dpop_proof"; + case OAuthBearerError::UseDPoPNonce: + return "use_dpop_nonce"; + } + + 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 if (code == "invalid_dpop_proof") { + return OAuthBearerError::InvalidDPoPProof; + } else if (code == "use_dpop_nonce") { + return OAuthBearerError::UseDPoPNonce; + } 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 + -> 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 HTTP_STATUS_UNAUTHORIZED; + } + + return HTTP_STATUS_BAD_REQUEST; +} + +} // 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..68b049e1b --- /dev/null +++ b/src/core/oauth/oauth_pkce.cc @@ -0,0 +1,126 @@ +#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 +#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. 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(entropy, encoded); + + 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..e1032654b --- /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.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 && + oauth_is_unreserved_string(value); +} + +} // namespace sourcemeta::core + +#endif 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/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..5ae98ef1a --- /dev/null +++ b/test/oauth/oauth_bearer_test.cc @@ -0,0 +1,390 @@ +#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) { + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="example")", "Bearer", "realm")}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "example"); +} + +TEST(challenge_parameter_finds_a_bare_token_value) { + const auto error{sourcemeta::core::oauth_challenge_parameter( + "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) { + const auto error{sourcemeta::core::oauth_challenge_parameter( + 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) { + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(bearer REALM="example")", "Bearer", "realm")}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "example"); +} + +TEST(challenge_parameter_tolerates_bad_whitespace_around_equals) { + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm = "example")", "Bearer", "realm")}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "example"); +} + +TEST(challenge_parameter_selects_the_named_scheme) { + const auto algs{sourcemeta::core::oauth_challenge_parameter( + 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) { + // 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")}; + EXPECT_FALSE(realm.has_value()); +} + +TEST(challenge_parameter_handles_an_empty_first_challenge) { + // 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")}; + EXPECT_TRUE(algs.has_value()); + EXPECT_EQ(algs.value(), "ES256 PS256"); +} + +TEST(challenge_parameter_keeps_a_comma_inside_a_quoted_value) { + 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_unescapes_a_quoted_pair) { + const auto realm{sourcemeta::core::oauth_challenge_parameter( + 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) { + const auto point{sourcemeta::core::oauth_challenge_parameter( + 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) { + const auto realm{sourcemeta::core::oauth_challenge_parameter( + 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) { + const auto url{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer resource_metadata="https://rs.example/.well-known/x")", + "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) { + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="example")", "Bearer", "error") + .has_value()); +} + +TEST(challenge_parameter_returns_nothing_for_an_absent_scheme) { + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="example")", "DPoP", "algs") + .has_value()); +} + +TEST(challenge_parameter_rejects_an_unterminated_quoted_string) { + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="example)", "Bearer", "realm") + .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")"}; + 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 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")}; + EXPECT_TRUE(realm.has_value()); + EXPECT_EQ(realm.value(), "Z"); +} + +TEST(challenge_parameter_finds_a_later_challenge_of_the_same_scheme) { + const auto error{sourcemeta::core::oauth_challenge_parameter( + 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) { + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Bearer realm="a=b:c")", "Bearer", "realm")}; + 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"(")"); + 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) { + const auto realm{sourcemeta::core::oauth_challenge_parameter( + 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) { + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(Negotiate a/b+c==, Bearer realm="x")", "Bearer", "realm")}; + 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 + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + 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 + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + R"(Bearer foo, resource_metadata="https://x")", "Bearer", + "resource_metadata") + .has_value()); +} + +TEST(challenge_parameter_rejects_a_parameter_before_any_scheme) { + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter(R"(realm="x")", + "Bearer", "realm") + .has_value()); +} + +TEST(challenge_parameter_rejects_an_empty_bare_value) { + EXPECT_FALSE(sourcemeta::core::oauth_challenge_parameter( + "Bearer realm=", "Bearer", "realm") + .has_value()); +} + +TEST(challenge_parameter_returns_nothing_for_an_empty_header) { + EXPECT_FALSE( + sourcemeta::core::oauth_challenge_parameter("", "Bearer", "realm") + .has_value()); +} + +TEST(challenge_parameter_returns_nothing_for_a_whitespace_header) { + EXPECT_FALSE( + sourcemeta::core::oauth_challenge_parameter(" ", "Bearer", "realm") + .has_value()); +} + +TEST(challenge_parameter_tolerates_leading_and_trailing_commas) { + const auto realm{sourcemeta::core::oauth_challenge_parameter( + R"(,,Bearer realm="x",,)", "Bearer", "realm")}; + 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 + const auto realm{sourcemeta::core::oauth_challenge_parameter( + 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()); +} + +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_error_test.cc b/test/oauth/oauth_error_test.cc new file mode 100644 index 000000000..113d16836 --- /dev/null +++ b/test/oauth/oauth_error_test.cc @@ -0,0 +1,322 @@ +#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"); + 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) { + 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); + 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) { + 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) { + 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) { + 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) + .code, + 400); +} diff --git a/test/oauth/oauth_pkce_test.cc b/test/oauth/oauth_pkce_test.cc new file mode 100644 index 000000000..1ab8b7322 --- /dev/null +++ b/test/oauth/oauth_pkce_test.cc @@ -0,0 +1,235 @@ +#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.2). + 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_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), which a 42 character value + // fails + 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)}; + 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()); +}