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