2021-06-06 16:03:13 +01:00
< ? php declare ( strict_types = 1 );
namespace Taproot\IndieAuth\Test ;
2021-06-08 23:06:35 +01:00
use Exception ;
2021-06-07 23:58:19 +01:00
use Nyholm\Psr7\Response ;
2021-06-07 19:32:02 +01:00
use Nyholm\Psr7\ServerRequest ;
2021-06-08 23:06:35 +01:00
use Nyholm\Psr7\Request ;
2021-06-06 16:03:13 +01:00
use PHPUnit\Framework\TestCase ;
2021-06-07 23:58:19 +01:00
use Psr\Http\Message\ServerRequestInterface ;
2021-06-08 23:06:35 +01:00
use Taproot\IndieAuth\Callback\DefaultAuthorizationForm ;
2021-06-07 19:32:02 +01:00
use Taproot\IndieAuth\Callback\SingleUserPasswordAuthenticationCallback ;
2021-06-08 23:06:35 +01:00
use Taproot\IndieAuth\IndieAuthException ;
2021-06-06 16:03:13 +01:00
use Taproot\IndieAuth\Server ;
2021-06-07 19:32:02 +01:00
use Taproot\IndieAuth\Storage\FilesystemJsonStorage ;
2021-06-08 23:06:35 +01:00
use function Taproot\IndieAuth\hashAuthorizationRequestParameters ;
use function Taproot\IndieAuth\urlComponentsMatch ;
2021-06-07 19:32:02 +01:00
const SERVER_SECRET = '1111111111111111111111111111111111111111111111111111111111111111' ;
const AUTH_CODE_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes' ;
const ACCESS_TOKEN_STORAGE_PATH = __DIR__ . '/tmp/authorization_codes' ;
2021-06-08 23:06:35 +01:00
const CODE_EXCEPTION_TEMPLATE_PATH = __DIR__ . '/templates/code_exception_response.txt.php' ;
const AUTHORIZATION_FORM_JSON_RESPONSE_TEMPLATE_PATH = __DIR__ . '/templates/authorization_form_json_response.json.php' ;
2021-06-07 19:32:02 +01:00
const TMP_DIR = __DIR__ . '/tmp' ;
2021-06-06 16:03:13 +01:00
class ServerTest extends TestCase {
2021-06-07 23:58:19 +01:00
protected function getDefaultServer ( array $config = []) {
return new Server ( array_merge ([
2021-06-07 19:32:02 +01:00
'secret' => SERVER_SECRET ,
'authorizationCodeStorage' => AUTH_CODE_STORAGE_PATH ,
'accessTokenStorage' => ACCESS_TOKEN_STORAGE_PATH ,
2021-06-08 23:06:35 +01:00
// With this template, IndieAuthException response bodies will contain only their IndieAuthException error code, for ease of comparison.
'exceptionTemplatePath' => CODE_EXCEPTION_TEMPLATE_PATH ,
// Default to a simple single-user password authentication handler.
Server :: HANDLE_AUTHENTICATION_REQUEST => new SingleUserPasswordAuthenticationCallback ([ 'me' => 'https://example.com/' ], password_hash ( 'password' , PASSWORD_DEFAULT ), Server :: DEFAULT_CSRF_KEY ),
'authorizationForm' => new DefaultAuthorizationForm ( AUTHORIZATION_FORM_JSON_RESPONSE_TEMPLATE_PATH ),
2021-06-07 23:58:19 +01:00
], $config ));
}
2021-06-08 23:06:35 +01:00
protected function getIARequest ( array $params = []) : ServerRequestInterface {
2021-06-07 23:58:19 +01:00
return ( new ServerRequest ( 'GET' , 'https://example.com/' )) -> withQueryParams ( array_merge ([
'response_type' => 'code' ,
'client_id' => 'https://app.example.com/' ,
'redirect_uri' => 'https://app.example.com/indieauth' ,
'state' => '12345' ,
'code_challenge' => hash ( 'sha256' , 'code' ),
'code_challenge_method' => 'sha256'
], $params ));
2021-06-07 19:32:02 +01:00
}
2021-06-08 23:06:35 +01:00
protected function getApprovalRequest ( bool $validCsrf = false , bool $addValidHash = false , ? array $queryParams = null , ? array $parsedBody = null ) : ServerRequestInterface {
$queryParams = $queryParams ? ? [];
$parsedBody = $parsedBody ? ? [];
$cookieParams = [];
$parsedBody [ Server :: APPROVE_ACTION_KEY ] = Server :: APPROVE_ACTION_VALUE ;
// Assume Middleware\DoubleSubmitCookieCsrfMiddleware is being used.
$csrfVal = 'random_and_secure_csrf_value' ;
if ( $validCsrf ) {
$parsedBody [ Server :: DEFAULT_CSRF_KEY ] = $csrfVal ;
$cookieParams = [
Server :: DEFAULT_CSRF_KEY => $csrfVal
];
}
$req = $this -> getIARequest ( $queryParams )
-> withMethod ( 'POST' )
-> withParsedBody ( $parsedBody )
-> withCookieParams ( $cookieParams );
if ( $addValidHash ) {
$req = $req -> withQueryParams ( array_merge ( $req -> getQueryParams (), [
Server :: HASH_QUERY_STRING_KEY => hashAuthorizationRequestParameters ( $req , SERVER_SECRET )
]));
}
return $req ;
}
2021-06-07 19:32:02 +01:00
protected function setUp () : void {
// Clean up tmp folder.
new FilesystemJsonStorage ( AUTH_CODE_STORAGE_PATH , - 1 , true );
new FilesystemJsonStorage ( ACCESS_TOKEN_STORAGE_PATH , - 1 , true );
@ rmdir ( AUTH_CODE_STORAGE_PATH );
@ rmdir ( ACCESS_TOKEN_STORAGE_PATH );
}
protected function tearDown () : void {
// Clean up tmp folder.
new FilesystemJsonStorage ( AUTH_CODE_STORAGE_PATH , - 1 , true );
new FilesystemJsonStorage ( ACCESS_TOKEN_STORAGE_PATH , - 1 , true );
@ rmdir ( AUTH_CODE_STORAGE_PATH );
@ rmdir ( ACCESS_TOKEN_STORAGE_PATH );
}
public function testAuthorizationRequestMissingParametersReturnsError () {
$s = $this -> getDefaultServer ();
2021-06-08 23:06:35 +01:00
$req = ( new ServerRequest ( 'GET' , 'https://example.com/' )) -> withQueryParams ([
'response_type' => 'code' // This param is required to identify the request as an IA authorization request.
]);
2021-06-07 19:32:02 +01:00
$res = $s -> handleAuthorizationEndpointRequest ( $req );
2021-06-08 23:06:35 +01:00
$this -> assertEquals (( string ) IndieAuthException :: REQUEST_MISSING_PARAMETER , ( string ) $res -> getBody ());
2021-06-07 23:58:19 +01:00
}
public function testUnauthenticatedRequestReturnsAuthenticationResponse () {
$expectedResponse = 'You need to authenticate before continuing!' ;
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) use ( $expectedResponse ) {
return new Response ( 200 , [ 'content-type' => 'text/plain' ], $expectedResponse );
}
]);
$res = $s -> handleAuthorizationEndpointRequest ( $this -> getIARequest ());
$this -> assertEquals ( 200 , $res -> getStatusCode ());
$this -> assertEquals ( $expectedResponse , ( string ) $res -> getBody ());
}
public function testReturnsServerErrorIfAuthenticationResultHasNoMeKey () {
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) {
return [];
}
]);
$res = $s -> handleAuthorizationEndpointRequest ( $this -> getIARequest ());
2021-06-08 23:06:35 +01:00
$this -> assertEquals (( string ) IndieAuthException :: AUTHENTICATION_CALLBACK_MISSING_ME_PARAM , ( string ) $res -> getBody ());
2021-06-07 23:58:19 +01:00
}
2021-06-08 23:06:35 +01:00
public function testReturnErrorIfFetchingClientIdThrowsException () {
$exceptionClasses = [
'GuzzleHttp\Exception\ConnectException' => ( string ) IndieAuthException :: HTTP_EXCEPTION_FETCHING_CLIENT_ID ,
'GuzzleHttp\Exception\RequestException' => ( string ) IndieAuthException :: HTTP_EXCEPTION_FETCHING_CLIENT_ID ,
'Exception' => ( string ) IndieAuthException :: INTERNAL_EXCEPTION_FETCHING_CLIENT_ID
];
foreach ( $exceptionClasses as $eClass => $expectedResponse ) {
2021-06-07 23:58:19 +01:00
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) {
return [ 'me' => 'https://example.com/' ];
},
2021-06-08 23:06:35 +01:00
'httpGetWithEffectiveUrl' => function ( $url ) use ( $eClass ) {
if ( $eClass == 'Exception' ) { throw new Exception (); }
throw new $eClass ( $eClass , new Request ( 'GET' , $url ));
2021-06-07 23:58:19 +01:00
}
]);
2021-06-08 23:06:35 +01:00
$res = $s -> handleAuthorizationEndpointRequest ( $this -> getIARequest ());
$this -> assertEquals ( $expectedResponse , ( string ) $res -> getBody ());
}
}
public function testReturnsErrorIfApprovalRequestHasNoHash () {
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) {
return [ 'me' => 'https://example.com' ];
}
]);
$res = $s -> handleAuthorizationEndpointRequest ( $this -> getApprovalRequest ( true , false ));
$this -> assertEquals (( string ) IndieAuthException :: AUTHORIZATION_APPROVAL_REQUEST_MISSING_HASH , ( string ) $res -> getBody ());
}
public function testReturnsErrorIfApprovalRequestHasInvalidHash () {
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) {
return [ 'me' => 'https://example.com' ];
}
]);
$req = $this -> getApprovalRequest ( true , false );
$req = $req -> withQueryParams ( array_merge ( $req -> getQueryParams (), [
Server :: HASH_QUERY_STRING_KEY => 'clearly not a valid hash'
]));
$res = $s -> handleAuthorizationEndpointRequest ( $req );
$this -> assertEquals (( string ) IndieAuthException :: AUTHORIZATION_APPROVAL_REQUEST_INVALID_HASH , ( string ) $res -> getBody ());
}
public function testValidApprovalRequestIsHandledCorrectly () {
// Make a valid authentication response with additional information, to make sure that it’ s saved
// in the authorization code.
$authenticationResponse = [
'me' => 'https://me.example.com/' ,
'profile' => [
'name' => 'Example Name'
]
];
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) use ( $authenticationResponse ) {
return $authenticationResponse ;
}
]);
// Make an approval request with valid CSRF tokens, a valid query parameter hash, one requested scope
// (different from the two granted scopes, so that we can test that requested and granted scopes are
// stored separately) and a redirect URI with a query string (so we can test that our IA query string
// parameters are appended correctly).
$grantedScopes = [ 'create' , 'update' ];
$req = $this -> getApprovalRequest ( true , true , [
'scope' => 'create' ,
'redirect_uri' => 'https://app.example.com/indieauth?client_redirect_query_string_param=value'
], [
'taproot_indieauth_server_scope[]' => $grantedScopes
]);
$res = $s -> handleAuthorizationEndpointRequest ( $req );
$this -> assertEquals ( 302 , $res -> getStatusCode (), 'The Response from a successful approval request must be a 302 redirect.' );
$responseLocation = $res -> getHeaderLine ( 'location' );
$queryParams = $req -> getQueryParams ();
parse_str ( parse_url ( $responseLocation , PHP_URL_QUERY ), $redirectUriQueryParams );
$this -> assertTrue ( urlComponentsMatch ( $responseLocation , $queryParams [ 'redirect_uri' ], [ PHP_URL_SCHEME , PHP_URL_HOST , PHP_URL_USER , PHP_URL_PORT , PHP_URL_HOST , PHP_URL_PORT , PHP_URL_PATH ]), 'The successful redirect response location did not match the redirect URI up to the path.' );
$this -> assertEquals ( $redirectUriQueryParams [ 'state' ], $queryParams [ 'state' ], 'The redirect URI state parameter did not match the authorization request state parameter.' );
$this -> assertEquals ( 'value' , $redirectUriQueryParams [ 'client_redirect_query_string_param' ], 'Query string params in the client app redirect_uri were not correctly preserved.' );
$storage = new FilesystemJsonStorage ( AUTH_CODE_STORAGE_PATH );
$storedCode = $storage -> get ( $redirectUriQueryParams [ 'code' ]);
$this -> assertNotNull ( $storedCode , 'An authorization code should be stored after a successful approval request.' );
foreach ([ 'client_id' , 'redirect_uri' , 'state' , 'code_challenge' , 'code_challenge_method' ] as $p ) {
$this -> assertEquals ( $queryParams [ $p ], $storedCode [ $p ], " Parameter $p in the stored code ( { $storedCode [ $p ] } ) was not the same as the request parameter ( $queryParams[$p] ). " );
2021-06-07 23:58:19 +01:00
}
2021-06-08 23:06:35 +01:00
$this -> assertTrue ( scopeEquals ( $queryParams [ 'scope' ], $storedCode [ 'requested_scope' ]), " The requested scopes in the stored code ( { $storedCode [ 'requested_scope' ] } ) did not match the scopes in the scope query parameter ( { $queryParams [ 'scope' ] } ). " );
$this -> assertTrue ( scopeEquals ( $grantedScopes , $storedCode [ 'scope' ]), " The granted scopes in the stored code ( { $storedCode [ 'scope' ] } ) did not match the granted scopes from the authorization form response ( " . join ( ' ' , $grantedScopes ) . " ). " );
$this -> assertEquals ( $authenticationResponse [ 'me' ], $storedCode [ 'me' ], " The “me” value in the stored code ( { $storedCode [ 'me' ] } ) did not match the “me” value from the authentication response ( { $authenticationResponse [ 'me' ] } ). " );
$this -> assertEquals ( $authenticationResponse [ 'profile' ], $storedCode [ 'profile' ], " The “profile” value in the stored code did not match the “profile” value from the authentication response. " );
2021-06-07 23:58:19 +01:00
}
2021-06-08 23:06:35 +01:00
public function testReturnsErrorIfRedirectUriDoesntMatchClientIdWithNoParsedRedirectUris () {
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) : array {
return [ 'me' => 'https://me.example.com' ];
},
'httpGetWithEffectiveUrl' => function ( $url ) : array {
// An empty response suffices for this test.
return [
new Response ( 200 , [ 'content-type' => 'text/html' ], '' ),
$url
];
}
]);
$req = $this -> getIARequest ([
'client_id' => 'https://client.example.com/' ,
'redirect_uri' => 'https://not-the-client.example.com/auth'
]);
$res = $s -> handleAuthorizationEndpointRequest ( $req );
$this -> assertEquals (( string ) IndieAuthException :: INVALID_REDIRECT_URI , ( string ) $res -> getBody ());
}
public function testReturnsErrorIfRedirectUriDoesntMatchClientIdOrParsedRedirectUris () {
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) : array {
return [ 'me' => 'https://me.example.com' ];
},
'httpGetWithEffectiveUrl' => function ( $url ) : array {
// Pass some tricky values to test for correct rel parsing.
return [
new Response ( 200 , [
'content-type' => 'text/html' ,
'link' => [
'<https://not-the-client.example.com/auth>; rel="wrong_redirect_uri_rel"' , // Matches redirect_uri but has wrong rel
'<https://invalid.example.com/redirect>; rel="redirect_uri"' // redirect_uri is correct but url is invalid.
]
],
<<< EOT
Rel correct , href not : < link rel = " redirect_uri " href = " https://yet-another-invalid.example.com/redirect " />
href matches redirect_uri , wrong rel : < link rel = " another_incorrect_redirect_uri " href = " https://not-the-client.example.com/auth " />
EOT
),
$url
];
}
]);
$req = $this -> getIARequest ([
'client_id' => 'https://client.example.com/' ,
'redirect_uri' => 'https://not-the-client.example.com/auth'
]);
$res = $s -> handleAuthorizationEndpointRequest ( $req );
$this -> assertEquals (( string ) IndieAuthException :: INVALID_REDIRECT_URI , ( string ) $res -> getBody ());
}
public function testReturnsAuthorizationFormIfClientIdSufficientlyMatchesRedirectUri () {
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) : array {
return [ 'me' => 'https://me.example.com' ];
},
'httpGetWithEffectiveUrl' => function ( $url ) : array {
return [
new Response ( 200 , [ 'content-type' => 'text/html' ], '' ), // An empty response suffices for this test.
$url
];
}
]);
$req = $this -> getIARequest ([
'client_id' => 'https://client.example.com/' ,
'redirect_uri' => 'https://client.example.com/auth'
]);
$res = $s -> handleAuthorizationEndpointRequest ( $req );
$this -> assertEquals ( 200 , $res -> getStatusCode ());
}
public function testReturnsAuthorizationFormIfClientIdExactlyMatchesParsedLinkHeaderRedirectUri () {
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) : array {
return [ 'me' => 'https://me.example.com' ];
},
'httpGetWithEffectiveUrl' => function ( $url ) : array {
return [
new Response ( 200 , [
'content-type' => 'text/html' ,
'link' => '<https://link-header.example.com/auth>; rel="another_rel redirect_uri"'
],
''
),
$url
];
}
]);
$req = $this -> getIARequest ([
'client_id' => 'https://client.example.com/' ,
'redirect_uri' => 'https://link-header.example.com/auth'
]);
$res = $s -> handleAuthorizationEndpointRequest ( $req );
$this -> assertEquals ( 200 , $res -> getStatusCode ());
}
public function testReturnsAuthorizationFormIfClientIdExactlyMatchesParsedLinkElementRedirectUri () {
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) : array {
return [ 'me' => 'https://me.example.com' ];
},
'httpGetWithEffectiveUrl' => function ( $url ) : array {
return [
new Response ( 200 , [ 'content-type' => 'text/html' ],
'<link rel="redirect_uri another_rel" href="https://link-element.example.com/auth" />'
),
$url
];
}
]);
$req = $this -> getIARequest ([
'client_id' => 'https://client.example.com/' ,
'redirect_uri' => 'https://link-element.example.com/auth'
]);
$res = $s -> handleAuthorizationEndpointRequest ( $req );
$this -> assertEquals ( 200 , $res -> getStatusCode ());
}
public function testFindsFirstHAppExactlyMatchingClientId () {
$correctHAppName = 'Correct h-app!' ;
$correctHAppUrl = 'https://client.example.com/' ;
$correctHAppPhoto = 'https://client.example.com/logo.png' ;
$s = $this -> getDefaultServer ([
Server :: HANDLE_AUTHENTICATION_REQUEST => function ( ServerRequestInterface $request , string $formAction ) {
return [ 'me' => 'https://me.example.com' ];
},
'httpGetWithEffectiveUrl' => function ( $url ) use ( $correctHAppPhoto , $correctHAppName , $correctHAppUrl ) {
return [
new Response ( 200 , [ 'content-type' => 'text/html' ],
<<< EOT
< a class = " h-app " href = " https://not-the-client.example.com/ " > Wrong </ a >
< a class = " h-app " href = " { $correctHAppUrl } " >< img src = " { $correctHAppPhoto } " alt = " { $correctHAppName } " /></ a >
EOT
),
$url
];
}
]);
$req = $this -> getIARequest ([
'client_id' => $correctHAppUrl ,
'redirect_uri' => 'https://client.example.com/auth'
]);
$res = $s -> handleAuthorizationEndpointRequest ( $req );
$this -> assertEquals ( 200 , $res -> getStatusCode ());
$parsedResponse = json_decode (( string ) $res -> getBody (), true );
$flatHApp = $parsedResponse [ 'clientHApp' ];
$this -> assertEquals ( $correctHAppUrl , $flatHApp [ 'url' ]);
$this -> assertEquals ( $correctHAppName , $flatHApp [ 'name' ]);
$this -> assertEquals ( $correctHAppPhoto , $flatHApp [ 'photo' ]);
}
public function testNonIndieAuthRequestWithDefaultHandlerReturnsError () {
$res = $this -> getDefaultServer () -> handleAuthorizationEndpointRequest ( new ServerRequest ( 'GET' , 'https://example.com' ));
$this -> assertEquals (( string ) IndieAuthException :: INTERNAL_ERROR , ( string ) $res -> getBody ());
}
public function testResponseReturnedFromNonIndieAuthRequestHandler () {
$responseBody = 'A response to a non-indieauth request.' ;
$res = $this -> getDefaultServer ([
Server :: HANDLE_NON_INDIEAUTH_REQUEST => function ( ServerRequestInterface $request ) use ( $responseBody ) {
return new Response ( 200 , [ 'content-type' => 'text/plain' ], $responseBody );
}
]) -> handleAuthorizationEndpointRequest ( new ServerRequest ( 'GET' , 'https://example.com' ));
$this -> assertEquals ( $responseBody , ( string ) $res -> getBody ());
}
}
function scopeEquals ( $scope1 , $scope2 ) : bool {
$scope1 = is_string ( $scope1 ) ? explode ( ' ' , $scope1 ) : $scope1 ;
$scope2 = is_string ( $scope2 ) ? explode ( ' ' , $scope2 ) : $scope2 ;
sort ( $scope1 );
sort ( $scope2 );
return $scope1 == $scope2 ;
2021-06-06 16:03:13 +01:00
}