| @ -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 | |||
| . | |||