diff --git a/src/functions.php b/src/functions.php index be31875..f82bd65 100644 --- a/src/functions.php +++ b/src/functions.php @@ -25,6 +25,14 @@ function generateRandomString($numBytes) { return bin2hex($bytes); } +function generatePKCECodeChallenge($plaintext) { + return base64_urlencode(hash('sha256', $plaintext, true)); +} + +function base64_urlencode($string) { + return rtrim(strtr(base64_encode($string), '+/', '-_'), '='); +} + function hashAuthorizationRequestParameters(ServerRequestInterface $request, string $secret, ?string $algo=null, ?array $hashedParameters=null): ?string { $hashedParameters = $hashedParameters ?? ['client_id', 'redirect_uri', 'code_challenge', 'code_challenge_method']; $algo = $algo ?? 'sha256'; @@ -127,3 +135,78 @@ function renderTemplate(string $template, array $context=[]) { }; return $render($template, $context); } + +// IndieAuth/OAuth2-related Validation Functions +// Mostly taken or adapted by https://github.com/Zegnat/php-mindee/ — thanks Zegnat! +// Code was not licensed at time of writing, permission granted here https://chat.indieweb.org/dev/2021-06-10/1623327498355700 + +/** + * Check if a provided string matches the IndieAuth criteria for a Client Identifier. + * @see https://indieauth.spec.indieweb.org/#client-identifier + * + * @param string $client_id The client ID provided by the OAuth Client + * @return bool true if the value is allowed by IndieAuth + */ +function isClientIdentifier(string $client_id): bool { + return ($url_components = parse_url($client_id)) && // Clients are identified by a URL. + in_array($url_components['scheme'] ?? '', ['http', 'https']) && // Client identifier URLs MUST have either an https or http scheme, + 0 < strlen($url_components['path'] ?? '') && // MUST contain a path component, + false === strpos($url_components['path'], '/./') && // MUST NOT contain single-dot + false === strpos($url_components['path'], '/../') && // or double-dot path segments, + false === isset($url_components['fragment']) && // MUST NOT contain a fragment component, + false === isset($url_components['user']) && // MUST NOT contain a username + false === isset($url_components['pass']) && // or password component, + ( + false === filter_var($url_components['host'], FILTER_VALIDATE_IP) || // MUST NOT be an IP address + ($url_components['host'] ?? null) == '127.0.0.1' || // except for 127.0.0.1 + ($url_components['host'] ?? null) == '[::1]' // or [::1] + ) + ; +} + +/** + * Check if a provided string matches the IndieAuth criteria for a User Profile URL. + * @see https://indieauth.spec.indieweb.org/#user-profile-url + * + * @param string $profile_url The profile URL provided by the IndieAuth Client as me + * @return bool true if the value is allowed by IndieAuth + */ +function isProfileUrl(string $profile_url): bool { + return ($url_components = parse_url($profile_url)) && // Users are identified by a URL. + in_array($url_components['scheme'] ?? '', ['http', 'https']) && // Profile URLs MUST have either an https or http scheme, + 0 < strlen($url_components['path'] ?? '') && // MUST contain a path component, + false === strpos($url_components['path'], '/./') && // MUST NOT contain single-dot + false === strpos($url_components['path'], '/../') && // or double-dot path segments, + false === isset($url_components['fragment']) && // MUST NOT contain a fragment component, + false === isset($url_components['user']) && // MUST NOT contain a username + false === isset($url_components['pass']) && // or password component, + false === isset($url_components['port']) && // MUST NOT contain a port, + false === filter_var($url_components['host'], FILTER_VALIDATE_IP) // MUST NOT be an IP address. + ; +} + +/** + * OAuth 2.0 limits what values are valid for state. + * We check this first, because if valid, we want to send it along with other errors. + * @see https://tools.ietf.org/html/rfc6749#appendix-A.5 + */ +function isValidState(string $state): bool { + return false !== filter_var($state, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[\x20-\x7E]*$/']]); +} + +/** + * IndieAuth requires PKCE. This implementation supports only S256 for hashing. + * + * @see https://indieauth.spec.indieweb.org/#authorization-request + */ +function isValidCodeChallenge(string $challenge): bool { + return false !== filter_var($challenge, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^[A-Za-z0-9_-]+$/']]); +} + +/** + * OAuth 2.0 limits what values are valid for scope. + * @see https://tools.ietf.org/html/rfc6749#section-3.3 + */ +function isValidScope(string $scope): bool { + return false !== filter_var($scope, FILTER_VALIDATE_REGEXP, ['options' => ['regexp' => '/^([\x21\x23-\x5B\x5D-\x7E]+( [\x21\x23-\x5B\x5D-\x7E]+)*)?$/']]); +} diff --git a/tests/FunctionTest.php b/tests/FunctionTest.php index 05086a0..023a390 100644 --- a/tests/FunctionTest.php +++ b/tests/FunctionTest.php @@ -6,6 +6,13 @@ use GuzzleHttp\Psr7\ServerRequest; use PHPUnit\Framework\TestCase; use Taproot\IndieAuth as IA; +use function Taproot\IndieAuth\generatePKCECodeChallenge; +use function Taproot\IndieAuth\isClientIdentifier; +use function Taproot\IndieAuth\isProfileUrl; +use function Taproot\IndieAuth\isValidScope; +use function Taproot\IndieAuth\isValidState; +use function Taproot\IndieAuth\isValidCodeChallenge; + class FunctionTest extends TestCase { public function testGenerateRandomString() { $len = 10; @@ -56,4 +63,79 @@ class FunctionTest extends TestCase { ])); $this->assertEquals(IA\hashAuthorizationRequestParameters($req1, 'super secret'), IA\hashAuthorizationRequestParameters($req2, 'super secret')); } + + // Taken straight from https://indieauth.spec.indieweb.org/#user-profile-url-li-6 + public function testIsProfileUrl() { + $testCases = [ + 'https://example.com/' => true, + 'https://example.com/username' => true, + 'https://example.com/users?id=100' => true, + 'example.com' => false, + 'mailto:user@example.com' => false, + 'https://example.com/foo/../bar' => false, + 'https://example.com/#me' => false, + 'https://user:pass@example.com/' => false, + 'https://example.com:8443/' => false, + 'https://172.28.92.51/' => false + ]; + + foreach ($testCases as $url => $expected) { + $this->assertEquals($expected, isProfileUrl($url), "$url was not correctly validated as $expected"); + } + } + + public function testIsClientIentifier() { + $testCases = [ + 'https://example.com/' => true, + 'https://example.com/username' => true, + 'https://example.com/users?id=100' => true, + 'https://example.com:8443/' => true, + 'https://127.0.0.1/' => true, + 'https://[1::]/' => true, + 'example.com' => false, + 'mailto:user@example.com' => false, + 'https://example.com/foo/../bar' => false, + 'https://example.com/#me' => false, + 'https://user:pass@example.com/' => false, + 'https://172.28.92.51/' => false + ]; + + foreach ($testCases as $url => $expected) { + $this->assertEquals($expected, isClientIdentifier($url), "$url was not correctly validated as $expected"); + } + } + + public function testIsValidState() { + $testCases = [ + 'hisdfbusdgiueryb@#$%^&*(' => true + ]; + + foreach ($testCases as $test => $expected) { + $this->assertEquals($expected, isValidState($test), "$test was not correctly validated as $expected"); + } + } + + public function testIsValidScope() { + $testCases = [ + '!#[]~' => true, + '!#[]~ scope1 another_scope moar_scopes!' => true, + '"' => false, // ASCII 0x22 not permitted + '\\' => false, // ASCII 0x5C not permitted + ]; + + foreach ($testCases as $test => $expected) { + $this->assertEquals($expected, isValidScope($test), "$test was not correctly validated as $expected"); + } + } + + public function testIsValidCodeChallenge() { + $testCases = [ + generatePKCECodeChallenge('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~') => true, + 'has_bad_characters_in_*%#ü____' => false + ]; + + foreach ($testCases as $test => $expected) { + $this->assertEquals($expected, isValidCodeChallenge($test), "$test was not correctly validated as $expected"); + } + } }