diff --git a/CHANGELOG.md b/CHANGELOG.md index 46c6cb4..7027a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [1.2.4] - 2018-09-05 + +### Fixed +- Completely refactored QSH generation logic (fixes #10) + ## [1.2.3] - 2018-08-15 ### Fixed @@ -56,7 +61,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Fixed - Package keywords at composer.json -[Unreleased]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.2.3...HEAD +[Unreleased]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.2.4...HEAD +[1.2.4]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.2.3...v1.2.4 [1.2.3]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.2.2...v1.2.3 [1.2.2]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.2.1...v1.2.2 [1.2.1]: https://github.com/brezzhnev/atlassian-connect-core/compare/v1.2.0...v1.2.1 diff --git a/src/Helpers/JWTHelper.php b/src/Helpers/JWTHelper.php index 0d1164a..d7da670 100644 --- a/src/Helpers/JWTHelper.php +++ b/src/Helpers/JWTHelper.php @@ -2,6 +2,8 @@ namespace AtlassianConnectCore\Helpers; +use AtlassianConnectCore\Http\Auth\QSH; + /** * Class JWTHelper * @@ -64,42 +66,9 @@ public static function create(string $url, string $method, string $issuer, strin * * @return string */ - public static function qsh($url, $method) + public static function qsh($url, $method): string { - $method = strtoupper($method); - - $parts = parse_url($url); - $path = $parts['path']; - - // The list of prefixes which must be removed from the path - $prefixes = ['/wiki']; - - foreach ($prefixes as $prefix) { - $path = preg_replace('/^' . preg_quote($prefix, '/') . '/', '', $path); - } - - // Parse a query into the map of parameters - parse_str($parts['query'], $params); - - // Parameters should be sorted alphabetically - ksort($params); - - $canonicalQuery = http_build_query( - $params, - null, - '&', - PHP_QUERY_RFC3986 - ); - - $parts = [ - strtoupper($method), - $path, - $canonicalQuery - ]; - - $qsh = hash('sha256', implode('&', $parts)); - - return $qsh; + return new QSH($url, $method); } /** diff --git a/src/Http/Auth/QSH.php b/src/Http/Auth/QSH.php new file mode 100644 index 0000000..b2e1213 --- /dev/null +++ b/src/Http/Auth/QSH.php @@ -0,0 +1,240 @@ + + */ +class QSH +{ + /** + * The request URL. + * + * @var string + */ + protected $url; + + /** + * The request HTTP method. + * + * @var string + */ + protected $method; + + /** + * The URL parts (host, port, path...) + * + * @var array + */ + protected $parts = []; + + /** + * The list of prefixes which should be removed. + * + * @var array + */ + protected $prefixes = [ + '/wiki' + ]; + + /** + * QSH constructor. + * + * @param string $url + * @param string $method + */ + public function __construct(string $url, string $method) + { + $this->url = $url; + $this->parts = parse_url($url); + + $this->method = strtoupper($method); + } + + /** + * Create a QSH string. + * + * More details: + * https://docs.atlassian.com/DAC/bitbucket/concepts/qsh.html + * + * @return string + */ + public function create(): string + { + $parts = [ + $this->method, + $this->canonicalUri(), + $this->canonicalQuery() + ]; + + return hash('sha256', implode('&', $parts)); + } + + /** + * Make a canonical URI. + * + * @return string|null + */ + public function canonicalUri() + { + if(!$path = array_get($this->parts, 'path')) { + return '/'; + } + + // Remove a prefix of instance from the path + // Eg. remove `/wiki` part which means Confluence instance. + $uri = $this->removePrefix($path); + + // The canonical URI should not contain & characters. + // Therefore, any & characters should be URL-encoded to %26. + $uri = str_replace('&', '%26', $uri); + + // The canonical URI only ends with a / character if it is the only character. + $uri = $uri === '/' + ? $uri + : rtrim($uri, '/'); + + return $uri; + } + + /** + * Make a canonical query string. + * + * @return string|null + */ + public function canonicalQuery() + { + if(!$query = array_get($this->parts, 'query')) { + return null; + } + + $params = $this->parseQuery($query); + + // We should ignore the "JWT" parameter. + $params = array_filter($params, function(string $key) { + return strtolower($key) !== 'jwt'; + }, ARRAY_FILTER_USE_KEY); + + ksort($params); + + $query = $this->buildQuery($params); + + // Encode underscores. + $query = str_replace('_', '%20', $query); + + return $query; + } + + /** + * Remove a prefix from the URL path. + * + * @param string $path + * + * @return string + */ + protected function removePrefix(string $path): string + { + foreach ($this->prefixes as $prefix) { + $pattern = '/^' . preg_quote($prefix, '/') . '/'; + + if(preg_match($pattern, $path)) { + $path = preg_replace($pattern, '', $path); + + break; + } + } + + return $path; + } + + /** + * Parse a query to array of parameters. + * + * @param string $query + * + * @return array + */ + protected function parseQuery(string $query): array + { + $output = []; + + $query = ltrim($query, '?'); + + $parameters = explode('&', $query); + + foreach ($parameters as $parameter) { + list($key, $value) = array_pad(explode('=', $parameter), 2, null); + + $output = array_merge_recursive($output, [$key => $value]); + } + + return $output; + } + + /** + * Build a query accordingly to RFC3986 + * + * @param array $params + * + * @return string + */ + protected function buildQuery(array $params): string + { + $pieces = []; + + foreach ($this->encodeQueryParams($params) as $param => $values) { + $value = implode(',', $values); + + $pieces[] = implode('=', !$value + ? [$param] + : [$param, $value] + ); + } + + return implode('&', array_filter($pieces)); + } + + /** + * Encode query parameters. + * + * @param array $params + * + * @return array + */ + protected function encodeQueryParams(array $params): array + { + $encoded = []; + + array_walk($params, function($value, string $param) use (&$encoded) { + $key = str_replace('+', ' ', $param); + $key = rawurlencode(rawurldecode($key)); + + $values = array_wrap($value); + $values = array_map(function($value) { + $value = str_replace('+', ' ', $value); + return rawurlencode(rawurldecode($value)); + }, $values); + + $encoded[$key] = $values; + }); + + return $encoded; + } + + /** + * Convert an object to a string representation. + * + * @return string + */ + public function __toString() + { + return $this->create(); + } +} \ No newline at end of file diff --git a/tests/Auth/QSHTest.php b/tests/Auth/QSHTest.php new file mode 100644 index 0000000..e1fb370 --- /dev/null +++ b/tests/Auth/QSHTest.php @@ -0,0 +1,105 @@ +assertQSH('GET', 'https://addon.example.com', 'c88caad15a1c1a900b8ac08aa9686f4e8184539bea1deda36e2f649430df3239'); + $this->assertQSH('GET', 'https://example.atlassian.net/rest/api/2/issue/', '86b803b109338286ccafd963279ad7c7c8aa6b62397acb179faf24a5323b218f'); + // $this->assertQSH('GET', 'https://addon.example.com/wiki/title&description', 'b59dff3220c9473597d76ab6ea943b8e84e2a490e537bb4451ce57dc1bc1cd06'); + } + + public function testCanonicalUri() + { + $this->assertCanonicalUri('https://addon.example.com/', '/'); + $this->assertCanonicalUri('https://addon.example.com/wiki/issue', '/issue'); + $this->assertCanonicalUri('https://addon.example.com/wiki/title&description', '/title%26description'); + $this->assertCanonicalUri('https://example.atlassian.net/rest/api/2/issue/', '/rest/api/2/issue'); + } + + public function testCanonicalQuery() + { + // Ignore the jwt parameter + $this->assertCanonicalQuery('jwt=ABC.DEF.GHI', ''); + $this->assertCanonicalQuery('expand=names&jwt=ABC.DEF.GHI', 'expand=names'); + + // // URL-encode parameter keys + $this->assertCanonicalQuery('enabled', 'enabled'); + $this->assertCanonicalQuery('some+spaces+in+this+parameter', 'some%20spaces%20in%20this%20parameter'); + $this->assertCanonicalQuery('connect*', 'connect%2A'); + $this->assertCanonicalQuery('1+%2B+1+equals+3', '1%20%2B%201%20equals%203'); + $this->assertCanonicalQuery('in+%7E3+days', 'in%20~3%20days'); + + // URL-encode parameter values + // For each parameter concatenate its URL-encoded name and its URL-encoded value with the = character. + $this->assertCanonicalQuery('param=value', 'param=value'); + $this->assertCanonicalQuery('param=some+spaces+in+this+parameter', 'param=some%20spaces%20in%20this%20parameter'); + $this->assertCanonicalQuery('query=connect*', 'query=connect%2A'); + $this->assertCanonicalQuery('a=b&', 'a=b'); + $this->assertCanonicalQuery('director=%E5%AE%AE%E5%B4%8E%20%E9%A7%BF', 'director=%E5%AE%AE%E5%B4%8E%20%E9%A7%BF'); + + // URL-encoding is upper case + $this->assertCanonicalQuery('director=%e5%ae%ae%e5%b4%8e%20%e9%a7%bf', 'director=%E5%AE%AE%E5%B4%8E%20%E9%A7%BF'); + + // Sort query parameter keys + $this->assertCanonicalQuery('a=x&b=y', 'a=x&b=y'); + $this->assertCanonicalQuery('a10=1&a1=2&b1=3&b10=4', 'a1=2&a10=1&b1=3&b10=4'); + $this->assertCanonicalQuery('A=A&a=a&b=b&B=B', 'A=A&B=B&a=a&b=b'); + + // Sort query parameter value lists + // In the case of repeated parameters, concatenate sorted values with a , character. + $this->assertCanonicalQuery('ids=-1&ids=1&ids=10&ids=2&ids=20', 'ids=-1,1,10,2,20'); + $this->assertCanonicalQuery('ids=.1&ids=.2&ids=%3A1&ids=%3A2', 'ids=.1,.2,%3A1,%3A2'); + $this->assertCanonicalQuery('ids=10%2C2%2C20%2C1', 'ids=10%2C2%2C20%2C1'); + $this->assertCanonicalQuery('tuples=1%2C2%2C3&tuples=6%2C5%2C4&tuples=7%2C9%2C8', 'tuples=1%2C2%2C3,6%2C5%2C4,7%2C9%2C8'); + $this->assertCanonicalQuery('chars=%E5%AE%AE&chars=%E5%B4%8E&chars=%E9%A7%BF', 'chars=%E5%AE%AE,%E5%B4%8E,%E9%A7%BF'); + $this->assertCanonicalQuery('c=&c=+&c=%2520&c=%2B', 'c=,%20,%2520,%2B'); + $this->assertCanonicalQuery('a=x1&a=x10&b=y1&b=y10', 'a=x1,x10&b=y1,y10'); + $this->assertCanonicalQuery('a=another+one&a=one+string&b=and+yet+more&b=more+here', 'a=another%20one,one%20string&b=and%20yet%20more,more%20here'); + $this->assertCanonicalQuery('a=1%2C2%2C3&a=4%2C5%2C6&b=a%2Cb%2Cc&b=d%2Ce%2Cf', 'a=1%2C2%2C3,4%2C5%2C6&b=a%2Cb%2Cc,d%2Ce%2Cf'); + } + + /** + * Assert generated canonical URI with expected. + * + * @param string $url + * @param string $expected + */ + protected function assertCanonicalUri(string $url, string $expected) + { + $qsh = new QSH($url, 'GET'); + + static::assertSame($expected, $qsh->canonicalUri()); + } + + /** + * Assert generated canonical query string with expected. + * + * @param string $query + * @param string $expected + */ + protected function assertCanonicalQuery(string $query, string $expected) + { + $qsh = new QSH('https://example.atlassian.net/?' . $query, 'GET'); + + static::assertSame($expected, $qsh->canonicalQuery()); + } + + /** + * Assert generated QSH with expected. + * + * @param string $method + * @param string $url + * @param string $expected + */ + protected function assertQSH(string $method, string $url, string $expected) + { + $qsh = new QSH($url, $method); + + static::assertSame($expected, $qsh->create()); + } +} \ No newline at end of file