| @ -0,0 +1,119 @@ | |||||
| <?php | |||||
| use Symfony\Component\HttpFoundation\Request; | |||||
| use Symfony\Component\HttpFoundation\Response; | |||||
| class Token { | |||||
| public $http; | |||||
| private $_pretty = false; | |||||
| public function __construct() { | |||||
| $this->http = new p3k\HTTP(); | |||||
| } | |||||
| public function token(Request $request, Response $response) { | |||||
| if($request->get('pretty')) { | |||||
| $this->_pretty = true; | |||||
| } | |||||
| $source = $request->get('source'); | |||||
| $code = $request->get('code'); | |||||
| if(!$source) { | |||||
| return $this->respond($response, 400, [ | |||||
| 'error' => 'invalid_request', | |||||
| 'error_description' => 'Provide a source URL' | |||||
| ]); | |||||
| } | |||||
| if(!$code) { | |||||
| return $this->respond($response, 400, [ | |||||
| 'error' => 'invalid_request', | |||||
| 'error_description' => 'Provide an authorization code' | |||||
| ]); | |||||
| } | |||||
| $scheme = parse_url($source, PHP_URL_SCHEME); | |||||
| if(!in_array($scheme, ['http','https'])) { | |||||
| return $this->respond($response, 400, [ | |||||
| 'error' => 'invalid_url', | |||||
| 'error_description' => 'Only http and https URLs are supported' | |||||
| ]); | |||||
| } | |||||
| // First try to discover the token endpoint | |||||
| $head = $this->http->head($source); | |||||
| if(!array_key_exists('Link', $head['headers'])) { | |||||
| return $this->respond($response, 200, [ | |||||
| 'error' => 'no_token_endpoint', | |||||
| 'error_description' => 'No Link headers were returned' | |||||
| ]); | |||||
| } | |||||
| if(is_string($head['headers']['Link'])) | |||||
| $head['headers']['Link'] = [$head['headers']['Link']]; | |||||
| $rels = p3k\HTTP::link_rels($head['headers']); | |||||
| $endpoint = false; | |||||
| if(array_key_exists('token_endpoint', $rels)) { | |||||
| $endpoint = $rels['token_endpoint'][0]; | |||||
| } elseif(array_key_exists('oauth2-token', $rels)) { | |||||
| $endpoint = $rels['oauth2-token'][0]; | |||||
| } | |||||
| if(!$endpoint) { | |||||
| return $this->respond($response, 200, [ | |||||
| 'error' => 'no_token_endpoint', | |||||
| 'error_description' => 'No token endpoint was found in the headers' | |||||
| ]); | |||||
| } | |||||
| // Resolve the endpoint URL relative to the source URL | |||||
| $endpoint = \mf2\resolveUrl($source, $endpoint); | |||||
| // Now exchange the code for a token | |||||
| $token = $this->http->post($endpoint, [ | |||||
| 'grant_type' => 'authorization_code', | |||||
| 'code' => $code | |||||
| ]); | |||||
| // Catch HTTP errors here such as timeouts | |||||
| if($token['error']) { | |||||
| return $this->respond($response, 400, [ | |||||
| 'error' => $token['error'], | |||||
| 'error_description' => $token['error_description'] ?: 'An unknown error occurred trying to fetch the token' | |||||
| ]); | |||||
| } | |||||
| // Otherwise pass through the response from the token endpoint | |||||
| $body = @json_decode($token['body']); | |||||
| // Pass through the content type if we were not able to decode the response as JSON | |||||
| $headers = []; | |||||
| if(!$body && isset($token['headers']['Content-Type'])) { | |||||
| $headers['Content-Type'] = $token['headers']['Content-Type']; | |||||
| } | |||||
| return $this->respond($response, $token['code'], $body ?: $token['body'], $headers); | |||||
| } | |||||
| private function respond(Response $response, $code, $params, $headers=[]) { | |||||
| $response->setStatusCode($code); | |||||
| foreach($headers as $k=>$v) { | |||||
| $response->headers->set($k, $v); | |||||
| } | |||||
| if(is_array($params) || is_object($params)) { | |||||
| $response->headers->set('Content-Type', 'application/json'); | |||||
| $opts = JSON_UNESCAPED_SLASHES; | |||||
| if($this->_pretty) $opts += JSON_PRETTY_PRINT; | |||||
| $response->setContent(json_encode($params, $opts)."\n"); | |||||
| } else { | |||||
| $response->setContent($params); | |||||
| } | |||||
| return $response; | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,156 @@ | |||||
| <?php | |||||
| use Symfony\Component\HttpFoundation\Request; | |||||
| use Symfony\Component\HttpFoundation\Response; | |||||
| class TokenTest extends PHPUnit_Framework_TestCase { | |||||
| private $http; | |||||
| public function setUp() { | |||||
| $this->client = new Token(); | |||||
| $this->client->http = new p3k\HTTPTest(dirname(__FILE__).'/data/'); | |||||
| } | |||||
| private function token($params) { | |||||
| $request = new Request($params); | |||||
| $response = new Response(); | |||||
| return $this->client->token($request, $response); | |||||
| } | |||||
| public function testMissingURL() { | |||||
| $response = $this->token([]); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(400, $response->getStatusCode()); | |||||
| $data = json_decode($body); | |||||
| $this->assertObjectHasAttribute('error', $data); | |||||
| $this->assertEquals('invalid_request', $data->error); | |||||
| } | |||||
| public function testInvalidURL() { | |||||
| $url = 'ftp://example.com/foo'; | |||||
| $response = $this->token(['source' => $url, 'code' => '1234']); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(400, $response->getStatusCode()); | |||||
| $data = json_decode($body); | |||||
| $this->assertObjectHasAttribute('error', $data); | |||||
| $this->assertEquals('invalid_url', $data->error); | |||||
| } | |||||
| public function testMissingCode() { | |||||
| $response = $this->token(['source' => 'http://example.com/']); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(400, $response->getStatusCode()); | |||||
| $data = json_decode($body); | |||||
| $this->assertObjectHasAttribute('error', $data); | |||||
| $this->assertEquals('invalid_request', $data->error); | |||||
| } | |||||
| public function testNoLinkHeaders() { | |||||
| $url = 'http://private.example.com/no-link-headers'; | |||||
| $response = $this->token(['source' => $url, 'code' => '1234']); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(200, $response->getStatusCode()); | |||||
| $data = json_decode($body); | |||||
| $this->assertObjectHasAttribute('error', $data); | |||||
| $this->assertEquals('no_token_endpoint', $data->error); | |||||
| } | |||||
| public function testNoTokenEndpointOneLinkHeader() { | |||||
| $url = 'http://private.example.com/no-token-endpoint-one-link-header'; | |||||
| $response = $this->token(['source' => $url, 'code' => '1234']); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(200, $response->getStatusCode()); | |||||
| $data = json_decode($body); | |||||
| $this->assertObjectHasAttribute('error', $data); | |||||
| $this->assertEquals('no_token_endpoint', $data->error); | |||||
| } | |||||
| public function testNoTokenEndpointTwoLinkHeaders() { | |||||
| $url = 'http://private.example.com/no-token-endpoint-two-link-headers'; | |||||
| $response = $this->token(['source' => $url, 'code' => '1234']); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(200, $response->getStatusCode()); | |||||
| $data = json_decode($body); | |||||
| $this->assertObjectHasAttribute('error', $data); | |||||
| $this->assertEquals('no_token_endpoint', $data->error); | |||||
| } | |||||
| public function testTokenEndpointInOAuth2Rel() { | |||||
| $url = 'http://private.example.com/oauth2-token-endpoint'; | |||||
| $response = $this->token(['source' => $url, 'code' => '1234']); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(200, $response->getStatusCode()); | |||||
| $data = json_decode($body); | |||||
| $this->assertObjectNotHasAttribute('error', $data); | |||||
| $this->assertEquals('1234', $data->access_token); | |||||
| } | |||||
| public function testTokenEndpointInIndieAuthRel() { | |||||
| $url = 'http://private.example.com/token-endpoint'; | |||||
| $response = $this->token(['source' => $url, 'code' => '1234']); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(200, $response->getStatusCode()); | |||||
| $data = json_decode($body); | |||||
| $this->assertObjectNotHasAttribute('error', $data); | |||||
| $this->assertEquals('1234', $data->access_token); | |||||
| } | |||||
| public function testTokenEndpointWithMultipleRelLinks() { | |||||
| $url = 'http://private.example.com/multiple-rels'; | |||||
| $response = $this->token(['source' => $url, 'code' => '1234']); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(200, $response->getStatusCode()); | |||||
| $data = json_decode($body); | |||||
| $this->assertObjectNotHasAttribute('error', $data); | |||||
| $this->assertEquals('1234', $data->access_token); | |||||
| } | |||||
| public function testBadTokenEndpointResponse() { | |||||
| $url = 'http://private.example.com/token-endpoint-bad-response'; | |||||
| $response = $this->token(['source' => $url, 'code' => '1234']); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(400, $response->getStatusCode()); | |||||
| $data = json_decode($body); | |||||
| $this->assertObjectHasAttribute('error', $data); | |||||
| $this->assertEquals('this-string-passed-through-from-token-endpoint', $data->error); | |||||
| } | |||||
| public function testTokenEndpointTimeout() { | |||||
| $url = 'http://private.example.com/token-endpoint-timeout'; | |||||
| $response = $this->token(['source' => $url, 'code' => '1234']); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(400, $response->getStatusCode()); | |||||
| $data = json_decode($body); | |||||
| $this->assertObjectHasAttribute('error', $data); | |||||
| $this->assertEquals('timeout', $data->error); | |||||
| } | |||||
| public function testTokenEndpointReturnsNotJSON() { | |||||
| $url = 'http://private.example.com/token-endpoint-notjson'; | |||||
| $response = $this->token(['source' => $url, 'code' => '1234']); | |||||
| $body = $response->getContent(); | |||||
| $this->assertEquals(400, $response->getStatusCode()); | |||||
| $this->assertEquals('text/plain', $response->headers->get('content-type')); | |||||
| $this->assertEquals('Invalid request', $body); | |||||
| } | |||||
| } | |||||
| @ -0,0 +1,9 @@ | |||||
| HTTP/1.1 401 Unauthorized | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: text/plain; charset=utf-8 | |||||
| Connection: keep-alive | |||||
| Link: </token>; rel="token_endpoint" | |||||
| Link: </webmention>; rel="webmention" | |||||
| This page uses the "token_endpoint" rel value defined in https://indieweb.org/obtaining-an-access-token | |||||
| @ -0,0 +1,7 @@ | |||||
| HTTP/1.1 401 Unauthorized | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: text/plain; charset=utf-8 | |||||
| Connection: keep-alive | |||||
| This page has no link headers. | |||||
| @ -0,0 +1,8 @@ | |||||
| HTTP/1.1 401 Unauthorized | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: text/plain; charset=utf-8 | |||||
| Connection: keep-alive | |||||
| Link: </micropub>; rel="micropub" | |||||
| This page has no token endpoint specified. | |||||
| @ -0,0 +1,9 @@ | |||||
| HTTP/1.1 401 Unauthorized | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: text/plain; charset=utf-8 | |||||
| Connection: keep-alive | |||||
| Link: </micropub>; rel="micropub" | |||||
| Link: </webmention>; rel="webmention" | |||||
| This page has no token endpoint specified. | |||||
| @ -0,0 +1,8 @@ | |||||
| HTTP/1.1 401 Unauthorized | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: text/plain; charset=utf-8 | |||||
| Connection: keep-alive | |||||
| Link: </token>; rel="oauth2-token" | |||||
| This page uses the "oauth2-token" rel value defined in https://tools.ietf.org/html/draft-wmills-oauth-lrdd-07 | |||||
| @ -0,0 +1,11 @@ | |||||
| HTTP/1.1 200 OK | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: application/json | |||||
| Connection: keep-alive | |||||
| { | |||||
| "access_token": "1234", | |||||
| "token_type": "bearer", | |||||
| "expires_in": 3600 | |||||
| } | |||||
| @ -0,0 +1,8 @@ | |||||
| HTTP/1.1 401 Unauthorized | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: text/plain; charset=utf-8 | |||||
| Connection: keep-alive | |||||
| Link: </token>; rel="token_endpoint" | |||||
| This page uses the "token_endpoint" rel value defined in https://indieweb.org/obtaining-an-access-token | |||||
| @ -0,0 +1,8 @@ | |||||
| HTTP/1.1 401 Unauthorized | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: text/plain; charset=utf-8 | |||||
| Connection: keep-alive | |||||
| Link: </token-invalid>; rel="token_endpoint" | |||||
| This page links to a token endpoint that will return a bad response | |||||
| @ -0,0 +1,8 @@ | |||||
| HTTP/1.1 401 Unauthorized | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: text/plain; charset=utf-8 | |||||
| Connection: keep-alive | |||||
| Link: </token-notjson>; rel="token_endpoint" | |||||
| This page links to a token endpoint that does not return JSON | |||||
| @ -0,0 +1,8 @@ | |||||
| HTTP/1.1 401 Unauthorized | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: text/plain; charset=utf-8 | |||||
| Connection: keep-alive | |||||
| Link: </token-timeout>; rel="token_endpoint" | |||||
| This page links to a token endpoint that will time out | |||||
| @ -0,0 +1,9 @@ | |||||
| HTTP/1.1 400 Bad Request | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: application/json | |||||
| Connection: keep-alive | |||||
| { | |||||
| "error": "this-string-passed-through-from-token-endpoint" | |||||
| } | |||||
| @ -0,0 +1,7 @@ | |||||
| HTTP/1.1 400 Bad Request | |||||
| Server: Apache | |||||
| Date: Wed, 09 Dec 2015 03:29:14 GMT | |||||
| Content-Type: text/plain | |||||
| Connection: keep-alive | |||||
| Invalid request | |||||
| @ -0,0 +1,4 @@ | |||||
| HTTP/1.1 400 Bad Request | |||||
| X-Test-Error: timeout | |||||
| . | |||||